Przeglądaj źródła

walle 2.0 预览版

meolu 6 lat temu
rodzic
commit
5760168212
85 zmienionych plików z 8358 dodań i 0 usunięć
  1. 53 0
      README_2.0.md
  2. 56 0
      admin.sh
  3. 19 0
      autoapp.py
  4. 1 0
      migrations/README
  5. 45 0
      migrations/alembic.ini
  6. 87 0
      migrations/env.py
  7. 24 0
      migrations/script.py.mako
  8. 148 0
      migrations/versions/6338426ab557_.py
  9. 18 0
      requirements/dev.txt
  10. 39 0
      requirements/prod.txt
  11. 11 0
      run.sh
  12. 11 0
      test.sh
  13. 1 0
      tests/__init__.py
  14. 62 0
      tests/conftest.py
  15. 31 0
      tests/factories.py
  16. 578 0
      tests/test_00_base.py
  17. 33 0
      tests/test_00_login.py
  18. 125 0
      tests/test_01_api_environment.py
  19. 119 0
      tests/test_02_api_role.py
  20. 163 0
      tests/test_03_api_user.py
  21. 44 0
      tests/test_04_api_passport.py
  22. 150 0
      tests/test_05_api_space.py
  23. 128 0
      tests/test_06_api_server.py
  24. 258 0
      tests/test_07_api_project.py
  25. 165 0
      tests/test_08_api_task.py
  26. 29 0
      tests/test_config.py
  27. 70 0
      tests/test_forms.py
  28. 120 0
      tests/test_functional.py
  29. 86 0
      tests/test_models.py
  30. 37 0
      tests/utils.py
  31. 11 0
      travis.test.sh
  32. 1 0
      walle/__init__.py
  33. 8 0
      walle/api/__init__.py
  34. 109 0
      walle/api/access.py
  35. 137 0
      walle/api/api.py
  36. 58 0
      walle/api/deploy.py
  37. 119 0
      walle/api/environment.py
  38. 88 0
      walle/api/general.py
  39. 162 0
      walle/api/group.py
  40. 57 0
      walle/api/passport.py
  41. 146 0
      walle/api/project.py
  42. 75 0
      walle/api/repo.py
  43. 29 0
      walle/api/role.py
  44. 109 0
      walle/api/server.py
  45. 136 0
      walle/api/space.py
  46. 138 0
      walle/api/task.py
  47. 182 0
      walle/api/user.py
  48. 241 0
      walle/app.py
  49. 126 0
      walle/commands.py
  50. 8 0
      walle/config/__init__.py
  51. 27 0
      walle/config/settings.py
  52. 51 0
      walle/config/settings_dev.py
  53. 19 0
      walle/config/settings_prod.py
  54. 50 0
      walle/config/settings_test.py
  55. 8 0
      walle/form/__init__.py
  56. 36 0
      walle/form/environment.py
  57. 50 0
      walle/form/group.py
  58. 93 0
      walle/form/project.py
  59. 21 0
      walle/form/role.py
  60. 32 0
      walle/form/server.py
  61. 41 0
      walle/form/space.py
  62. 21 0
      walle/form/tag.py
  63. 55 0
      walle/form/task.py
  64. 62 0
      walle/form/user.py
  65. 8 0
      walle/model/__init__.py
  66. 307 0
      walle/model/database.py
  67. 649 0
      walle/model/deploy.py
  68. 58 0
      walle/model/tag.py
  69. 884 0
      walle/model/user.py
  70. 8 0
      walle/service/__init__.py
  71. 39 0
      walle/service/code.py
  72. 386 0
      walle/service/deployer.py
  73. 118 0
      walle/service/emails.py
  74. 40 0
      walle/service/error.py
  75. 20 0
      walle/service/extensions.py
  76. 8 0
      walle/service/rbac/__init__.py
  77. 43 0
      walle/service/rbac/access.py
  78. 27 0
      walle/service/rbac/passport.py
  79. 129 0
      walle/service/rbac/role.py
  80. 85 0
      walle/service/tokens.py
  81. 38 0
      walle/service/utils.py
  82. 159 0
      walle/service/waller.py
  83. 72 0
      walle/service/websocket.py
  84. 47 0
      walle/templates/websocket.html
  85. 16 0
      waller.py

+ 53 - 0
README_2.0.md

@@ -0,0 +1,53 @@
+# walle-web
+
+walle-web.io a deployment kit
+
+
+[![Build Status](https://travis-ci.org/meolu/walle-web.svg?branch=development)](https://travis-ci.org/meolu/walle-web)
+
+Quickstart
+----------
+
+```
+# 开发分支尝鲜
+git clone https://github.com/meolu/walle-web
+cd walle-web
+git checkout development # 开发分支
+
+# 配置环境
+pip install virtualenv
+virtualenv venv
+source venv/bin/activate
+pip install -r requirements/dev.txt
+
+
+# 数据导入 mysql 新建一个 walle_python 库
+> create database walle_python;
+> source walle_python_with_data.sql
+
+后期精细化migration
+flask db init
+flask db migrate
+flask db upgrade
+
+# 修改数据库连接(自己找下,小小地考验下)
+vi walle/config/settings.py
+
+
+# 运行(内含Flask的一些配置)
+sh run.sh
+
+# 怎么可能没有标准的单元测试呢
+sh test.sh
+
+```
+勾搭下
+---------
+人脉也是一项非常重要能力,请备注姓名@公司,谢谢:)
+
+<img src="https://raw.githubusercontent.com/meolu/walle-web/master/docs/weixin.wushuiyong.jpg" width="244" height="314" alt="吴水永微信" align=left />
+
+<img src="https://raw.githubusercontent.com/meolu/walle-web/master/docs/chenfengjuan.jpeg" width="244" height="314" alt="孙恒哲微信" align=left />
+
+<img src="https://raw.githubusercontent.com/meolu/walle-web/master/docs/yexinhao.jpeg" width="280" height="314" alt="叶歆昊微信" align=left />
+

+ 56 - 0
admin.sh

@@ -0,0 +1,56 @@
+#########################################################################
+# File APP: admin.sh
+# Author: wushuiyong
+# mail: wushuiyong@walle-web.io
+# Created Time: 2018年11月03日 星期六 06时09分46秒
+#########################################################################
+#!/bin/bash
+
+APP="waller.py"
+ 
+function start() {
+    echo "start walle"
+    echo "----------------"
+    source ./venv/bin/activate
+    export FLASK_DEBUG=1
+    nohup python $APP &
+}
+ 
+function stop() {
+    echo "stop walle"
+    echo "----------------"
+    # 获取进程 PID
+    PID=$(ps -ef | grep $APP | grep -v grep | awk '{print $2}') 
+    # 杀死进程
+    kill -9 $PID
+}
+ 
+function restart() {
+    echo "restart walle"
+    echo "----------------"
+    stop
+    start
+}
+ 
+case "$1" in
+    start )
+        echo "****************"
+        start
+        echo "****************"
+        ;;
+    stop )
+        echo "****************"
+        stop
+        echo "****************"
+        ;;
+    restart )
+        echo "****************"
+        restart
+        echo "****************"
+        ;;
+    * )
+        echo "****************"
+        echo "no command"
+        echo "****************"
+        ;;
+esac

+ 19 - 0
autoapp.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+"""Create an application instance."""
+import sys
+
+from flask.helpers import get_debug_flag
+from walle.app import create_app
+from walle.config.settings_dev import DevConfig
+from walle.config.settings_test import TestConfig
+from walle.config.settings_prod import ProdConfig
+
+CONFIG = DevConfig if get_debug_flag(default=True) else ProdConfig
+
+# from flask_login import current_user
+
+if len(sys.argv) > 2 and sys.argv[2] == 'test':
+    CONFIG = TestConfig
+
+app = create_app(CONFIG)
+('============ @app.teardown_request ============')

+ 1 - 0
migrations/README

@@ -0,0 +1 @@
+Generic single-database configuration.

+ 45 - 0
migrations/alembic.ini

@@ -0,0 +1,45 @@
+# A generic, single database configuration.
+
+[alembic]
+# template used to generate migration files
+# file_template = %%(rev)s_%%(slug)s
+
+# set to 'true' to run the environment during
+# the 'revision' command, regardless of autogenerate
+# revision_environment = false
+
+
+# Logging configuration
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S

+ 87 - 0
migrations/env.py

@@ -0,0 +1,87 @@
+from __future__ import with_statement
+from alembic import context
+from sqlalchemy import engine_from_config, pool
+from logging.config import fileConfig
+import logging
+
+# this is the Alembic Config object, which provides
+# access to the values within the .ini file in use.
+config = context.config
+
+# Interpret the config file for Python logging.
+# This line sets up loggers basically.
+fileConfig(config.config_file_name)
+logger = logging.getLogger('alembic.env')
+
+# add your model's MetaData object here
+# for 'autogenerate' support
+# from myapp import mymodel
+# target_metadata = mymodel.Base.metadata
+from flask import current_app
+config.set_main_option('sqlalchemy.url',
+                       current_app.config.get('SQLALCHEMY_DATABASE_URI'))
+target_metadata = current_app.extensions['migrate'].db.metadata
+
+# other values from the config, defined by the needs of env.py,
+# can be acquired:
+# my_important_option = config.get_main_option("my_important_option")
+# ... etc.
+
+
+def run_migrations_offline():
+    """Run migrations in 'offline' mode.
+
+    This configures the context with just a URL
+    and not an Engine, though an Engine is acceptable
+    here as well.  By skipping the Engine creation
+    we don't even need a DBAPI to be available.
+
+    Calls to context.execute() here emit the given string to the
+    script output.
+
+    """
+    url = config.get_main_option("sqlalchemy.url")
+    context.configure(url=url)
+
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+def run_migrations_online():
+    """Run migrations in 'online' mode.
+
+    In this scenario we need to create an Engine
+    and associate a connection with the context.
+
+    """
+
+    # this callback is used to prevent an auto-migration from being generated
+    # when there are no changes to the schema
+    # reference: http://alembic.readthedocs.org/en/latest/cookbook.html
+    def process_revision_directives(context, revision, directives):
+        if getattr(config.cmd_opts, 'autogenerate', False):
+            script = directives[0]
+            if script.upgrade_ops.is_empty():
+                directives[:] = []
+                logger.info('No changes in schema detected.')
+
+    engine = engine_from_config(config.get_section(config.config_ini_section),
+                                prefix='sqlalchemy.',
+                                poolclass=pool.NullPool)
+
+    connection = engine.connect()
+    context.configure(connection=connection,
+                      target_metadata=target_metadata,
+                      process_revision_directives=process_revision_directives,
+                      **current_app.extensions['migrate'].configure_args)
+
+    try:
+        with context.begin_transaction():
+            context.run_migrations()
+    finally:
+        connection.close()
+
+if context.is_offline_mode():
+    run_migrations_offline()
+else:
+    run_migrations_online()

+ 24 - 0
migrations/script.py.mako

@@ -0,0 +1,24 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+
+def upgrade():
+    ${upgrades if upgrades else "pass"}
+
+
+def downgrade():
+    ${downgrades if downgrades else "pass"}

+ 148 - 0
migrations/versions/6338426ab557_.py

@@ -0,0 +1,148 @@
+"""empty message
+
+Revision ID: 6338426ab557
+Revises: 
+Create Date: 2017-05-18 14:43:27.361766
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '6338426ab557'
+down_revision = None
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('environment',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('name', sa.String(length=20), nullable=True),
+    sa.Column('status', sa.Integer(), nullable=True),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_table('foo',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('username', sa.String(length=50), nullable=False),
+    sa.Column('email', sa.String(length=100), nullable=True),
+    sa.Column('inserted_at', sa.DateTime(), nullable=True),
+    sa.Column('created_at', sa.DateTime(), nullable=True),
+    sa.Column('updated_at', sa.DateTime(), nullable=True),
+    sa.PrimaryKeyConstraint('id'),
+    sa.UniqueConstraint('email'),
+    sa.UniqueConstraint('username')
+    )
+    op.create_table('project',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('user_id', sa.Integer(), nullable=True),
+    sa.Column('name', sa.String(length=100), nullable=True),
+    sa.Column('environment_id', sa.Integer(), nullable=True),
+    sa.Column('status', sa.Integer(), nullable=True),
+    sa.Column('version', sa.String(length=40), nullable=True),
+    sa.Column('excludes', sa.Text(), nullable=True),
+    sa.Column('target_user', sa.String(length=50), nullable=True),
+    sa.Column('target_root', sa.String(length=200), nullable=True),
+    sa.Column('target_library', sa.String(length=200), nullable=True),
+    sa.Column('servers', sa.Text(), nullable=True),
+    sa.Column('prev_deploy', sa.Text(), nullable=True),
+    sa.Column('post_deploy', sa.Text(), nullable=True),
+    sa.Column('prev_release', sa.Text(), nullable=True),
+    sa.Column('post_release', sa.Text(), nullable=True),
+    sa.Column('post_release_delay', sa.Integer(), nullable=True),
+    sa.Column('keep_version_num', sa.Integer(), nullable=True),
+    sa.Column('repo_url', sa.String(length=200), nullable=True),
+    sa.Column('repo_username', sa.String(length=50), nullable=True),
+    sa.Column('repo_password', sa.String(length=50), nullable=True),
+    sa.Column('repo_mode', sa.String(length=50), nullable=True),
+    sa.Column('repo_type', sa.String(length=10), nullable=True),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_table('role',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('name', sa.String(length=30), nullable=True),
+    sa.Column('permission_ids', sa.Text(), nullable=True),
+    sa.Column('created_at', sa.DateTime(), nullable=True),
+    sa.Column('updated_at', sa.DateTime(), nullable=True),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_table('tag',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('name', sa.String(length=30), nullable=True),
+    sa.Column('label', sa.String(length=30), nullable=True),
+    sa.Column('created_at', sa.DateTime(), nullable=True),
+    sa.Column('updated_at', sa.DateTime(), nullable=True),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_table('task',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('user_id', sa.Integer(), nullable=True),
+    sa.Column('project_id', sa.Integer(), nullable=True),
+    sa.Column('action', sa.Integer(), nullable=True),
+    sa.Column('status', sa.Integer(), nullable=True),
+    sa.Column('title', sa.String(length=100), nullable=True),
+    sa.Column('link_id', sa.String(length=100), nullable=True),
+    sa.Column('ex_link_id', sa.String(length=100), nullable=True),
+    sa.Column('servers', sa.Text(), nullable=True),
+    sa.Column('commit_id', sa.String(length=40), nullable=True),
+    sa.Column('branch', sa.String(length=100), nullable=True),
+    sa.Column('file_transmission_mode', sa.Integer(), nullable=True),
+    sa.Column('file_list', sa.Text(), nullable=True),
+    sa.Column('enable_rollback', sa.Integer(), nullable=True),
+    sa.Column('created_at', sa.DateTime(), nullable=True),
+    sa.Column('updated_at', sa.DateTime(), nullable=True),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_table('task_record',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('stage', sa.String(length=20), nullable=True),
+    sa.Column('sequence', sa.Integer(), nullable=True),
+    sa.Column('user_id', sa.Integer(), nullable=True),
+    sa.Column('task_id', sa.Integer(), nullable=True),
+    sa.Column('status', sa.Integer(), nullable=True),
+    sa.Column('command', sa.String(length=200), nullable=True),
+    sa.Column('success', sa.String(length=2000), nullable=True),
+    sa.Column('error', sa.String(length=2000), nullable=True),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_table('user',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('username', sa.String(length=50), nullable=True),
+    sa.Column('is_email_verified', sa.Integer(), nullable=True),
+    sa.Column('email', sa.String(length=50), nullable=False),
+    sa.Column('password', sa.String(length=50), nullable=False),
+    sa.Column('avatar', sa.String(length=100), nullable=True),
+    sa.Column('role_id', sa.Integer(), nullable=True),
+    sa.Column('status', sa.Integer(), nullable=True),
+    sa.Column('created_at', sa.DateTime(), nullable=True),
+    sa.Column('updated_at', sa.DateTime(), nullable=True),
+    sa.PrimaryKeyConstraint('id'),
+    sa.UniqueConstraint('email')
+    )
+    op.create_table('user_group',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('user_id', sa.Integer(), nullable=True),
+    sa.Column('group_id', sa.Integer(), nullable=True),
+    sa.Column('created_at', sa.DateTime(), nullable=True),
+    sa.Column('updated_at', sa.DateTime(), nullable=True),
+    sa.ForeignKeyConstraint(['group_id'], ['tag.id'], ),
+    sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
+    sa.PrimaryKeyConstraint('id')
+    )
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_table('user_group')
+    op.drop_table('user')
+    op.drop_table('task_record')
+    op.drop_table('task')
+    op.drop_table('tag')
+    op.drop_table('role')
+    op.drop_table('project')
+    op.drop_table('foo')
+    op.drop_table('environment')
+    # ### end Alembic commands ###

+ 18 - 0
requirements/dev.txt

@@ -0,0 +1,18 @@
+# Everything the developer needs in addition to the production requirements
+-r prod.txt
+
+# Testing
+pytest==3.0.7
+WebTest==2.0.27
+factory-boy==2.8.1
+
+# Lint and code style
+flake8==3.3.0
+flake8-blind-except==0.1.1
+flake8-debugger==1.4.0
+flake8-docstrings==1.1.0
+flake8-isort==2.2
+flake8-quotes==0.9.0
+isort==4.2.5
+pep8-naming==0.4.1
+pysqlite==2.8.3

+ 39 - 0
requirements/prod.txt

@@ -0,0 +1,39 @@
+# Everything needed in production
+fabric2==2.3.1
+tornado==5.1
+
+# Flask
+Flask==0.12.2
+MarkupSafe==1.0
+Werkzeug==0.12.2
+Jinja2==2.9.6
+Flask-RESTful==0.3.5
+Flask-Babel==0.11.2
+Flask-Mail==0.9.0
+
+# Database
+Flask-SQLAlchemy==2.2
+psycopg2==2.7.1
+SQLAlchemy==1.1.9
+MySQL-python==1.2.5
+marshmallow==2.13.5
+
+# Migrations
+Flask-Migrate==2.0.3
+
+# Forms
+Flask-WTF==0.14.2
+WTForms==2.1
+
+# Deployment
+gunicorn>=19.1.1
+
+# Auth
+Flask-Login==0.4.0
+Flask-Bcrypt==0.7.1
+
+anyjson==0.3.3
+celery==3.1.18
+pycrypto==2.6.1
+pytz==2015.6
+requests==2.7.0

+ 11 - 0
run.sh

@@ -0,0 +1,11 @@
+#!/usr/bin/env zsh
+###################################################################
+# @Author: wushuiyong
+# @Created Time : 二  5/23 23:06:06 2017
+#
+# @File Name: run.sh
+# @Description:
+###################################################################
+source venv/bin/activate
+export FLASK_DEBUG=1
+python autoapp.py

+ 11 - 0
test.sh

@@ -0,0 +1,11 @@
+#!/usr/bin/env zsh
+###################################################################
+# @Author: wushuiyong
+# @Created Time : 二  5/23 23:06:06 2017
+#
+# @File Name: run.sh
+# @Description:
+###################################################################
+source venv/bin/activate
+export FLASK_APP=autoapp.py
+python -m flask test

+ 1 - 0
tests/__init__.py

@@ -0,0 +1 @@
+"""Tests for the app."""

+ 62 - 0
tests/conftest.py

@@ -0,0 +1,62 @@
+# -*- coding: utf-8 -*-
+"""Defines fixtures available to all tests."""
+
+import pytest
+
+from walle.app import create_app
+from walle.config.settings_test import TestConfig
+from walle.model.database import db as _db
+from webtest import TestApp
+from .factories import UserFactory
+
+
+@pytest.yield_fixture(scope='session')
+def app():
+    """An application for the tests."""
+    _app = create_app(TestConfig)
+    # _app.config['LOGIN_DISABLED'] = True
+    _app.login_manager.init_app(_app)
+    ctx = _app.test_request_context()
+    ctx.push()
+
+    yield _app
+
+    ctx.pop()
+
+
+@pytest.yield_fixture(scope='session')
+def client(app):
+    """A Flask test client. An instance of :class:`flask.testing.TestClient`
+    by default.
+    """
+    with app.test_client() as client:
+        yield client
+
+
+@pytest.fixture(scope='session')
+def testapp(app):
+    """A Webtest app."""
+
+    return TestApp(app)
+
+
+@pytest.yield_fixture(scope='session')
+def db(app):
+    """A database for the tests."""
+    _db.app = app
+    with app.app_context():
+        _db.create_all()
+
+    yield _db
+
+    # Explicitly close DB connection
+    _db.session.close()
+    _db.drop_all()
+
+
+@pytest.fixture(scope='session')
+def user(db):
+    """A user for the tests."""
+    user = UserFactory()
+    db.session.commit()
+    return user

+ 31 - 0
tests/factories.py

@@ -0,0 +1,31 @@
+# -*- coding: utf-8 -*-
+"""Factories to help in tests."""
+from factory import PostGenerationMethodCall, Sequence
+from factory.alchemy import SQLAlchemyModelFactory
+from werkzeug.security import generate_password_hash
+
+from walle.model.database import db
+from walle.model.user import UserModel
+
+
+class BaseFactory(SQLAlchemyModelFactory):
+    """Base factory."""
+
+    class Meta:
+        """Factory configuration."""
+
+        abstract = True
+        sqlalchemy_session = db.session
+
+
+class UserFactory(BaseFactory):
+    """User factory."""
+
+    username = Sequence(lambda n: 'test{0}'.format(n))
+    email = Sequence(lambda n: 'test{0}@walle.com'.format(n))
+    password = generate_password_hash('test0pwd')
+
+    class Meta:
+        """Factory configuration."""
+
+        model = UserModel

+ 578 - 0
tests/test_00_base.py

@@ -0,0 +1,578 @@
+# -*- coding: utf-8 -*-
+"""Model unit tests."""
+
+import pytest
+from walle.model.user import MenuModel
+from walle.model.user import RoleModel
+from walle.model.user import UserModel
+from werkzeug.security import generate_password_hash
+from copy import deepcopy
+user_data_login = {
+    'username': u'wushuiyong',
+    'email': u'wushuiyong@walle-web.io',
+    'password': u'WU123shuiyong',
+}
+
+
+@pytest.mark.usefixtures('db')
+class TestFoo:
+    """User tests."""
+
+    def test_get_by_id(self):
+        """Get user by ID."""
+        pass
+        # user = Foo(username='testuser', email='wushuiyong@mail.com')
+        # user.save()
+        # print(user.id)
+        #
+        # retrieved = Foo.get_by_id(user.id)
+        # assert retrieved == user
+
+
+class TestAccess:
+    def from_data(self):
+        return []
+
+    def test_add(self):
+        access_list = [
+            {
+                "archive": 0,
+                "created_at": u"2017-06-11 23:11:38",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 1,
+                "name_cn": u"用户中心",
+                "name_en": u"",
+                "pid": 0,
+                "sequence": 10001,
+                "type": u"module",
+                "updated_at": u"2017-06-12 00:15:29"
+            },
+            {
+                "archive": 0,
+                "created_at": u"2017-06-11 23:11:52",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 2,
+                "name_cn": u"配置中心",
+                "name_en": u"",
+                "pid": 0,
+                "sequence": 10002,
+                "type": u"module",
+                "updated_at": u"2017-06-12 00:15:29"
+            },
+            {
+                "archive": 0,
+                "created_at": u"2017-06-11 23:12:45",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 3,
+                "name_cn": u"上线单",
+                "name_en": u"",
+                "pid": 0,
+                "sequence": 10003,
+                "type": u"module",
+                "updated_at": u"2017-06-12 00:15:29"
+            },
+            {
+                "archive": 0,
+                "created_at": u"2017-06-11 23:13:51",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 11,
+                "name_cn": u"用户管理",
+                "name_en": u"user",
+                "pid": 1,
+                "sequence": 10101,
+                "type": u"controller",
+                "updated_at": u"2017-06-14 10:42:45"
+            },
+            {
+                "archive": 0,
+                "created_at": u"2017-06-11 23:14:11",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 12,
+                "name_cn": u"用户组",
+                "name_en": u"group",
+                "pid": 1,
+                "sequence": 10102,
+                "type": u"controller",
+                "updated_at": u"2017-06-14 10:42:48"
+            },
+            {
+                "archive": 0,
+                "created_at": u"2017-06-11 23:14:44",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 13,
+                "name_cn": u"角色",
+                "name_en": u"role",
+                "pid": 1,
+                "sequence": 10103,
+                "type": u"controller",
+                "updated_at": u"2017-06-14 10:42:52"
+            },
+            {
+                "archive": 0,
+                "created_at": u"2017-06-11 23:15:30",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 14,
+                "name_cn": u"环境管理",
+                "name_en": u"environment",
+                "pid": 2,
+                "sequence": 10201,
+                "type": u"controller",
+                "updated_at": u"2017-06-14 10:42:58"
+            },
+            {
+                "archive": 0,
+                "created_at": u"2017-06-11 23:15:51",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 15,
+                "name_cn": u"服务器管理",
+                "name_en": u"server",
+                "pid": 2,
+                "sequence": 10202,
+                "type": u"controller",
+                "updated_at": u"2017-06-14 10:43:01"
+            },
+            {
+                "archive": 0,
+                "created_at": u"2017-06-11 23:16:18",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 16,
+                "name_cn": u"项目管理",
+                "name_en": u"project",
+                "pid": 2,
+                "sequence": 10203,
+                "type": u"controller",
+                "updated_at": u"2017-06-14 10:43:07"
+            },
+            {
+                "archive": 0,
+                "created_at": u"2017-06-11 23:17:12",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 101,
+                "name_cn": u"查看",
+                "name_en": u"get",
+                "pid": 11,
+                "sequence": 11101,
+                "type": u"action",
+                "updated_at": u"2017-06-14 10:43:09"
+            },
+            {
+                "archive": 0,
+                "created_at": u"2017-06-11 23:17:26",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 102,
+                "name_cn": u"修改",
+                "name_en": u"put",
+                "pid": 11,
+                "sequence": 11102,
+                "type": u"action",
+                "updated_at": u"2017-06-14 10:43:17"
+            },
+            {
+                "archive": 0,
+                "created_at": u"2017-06-11 23:17:59",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 103,
+                "name_cn": u"新增",
+                "name_en": u"post",
+                "pid": 11,
+                "sequence": 11103,
+                "type": u"action",
+                "updated_at": u"2017-06-14 10:43:19"
+            },
+            {
+                "archive": 0,
+                "created_at": u"2017-06-11 23:18:16",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 104,
+                "name_cn": u"删除",
+                "name_en": u"delete",
+                "pid": 11,
+                "sequence": 11104,
+                "type": u"action",
+                "updated_at": u"2017-06-14 10:43:35"
+            },
+            {
+                "archive": 0,
+                "created_at": u"2017-06-19 08:14:56",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 105,
+                "name_cn": u"查看",
+                "name_en": u"get",
+                "pid": 12,
+                "sequence": 11201,
+                "type": u"action",
+                "updated_at": u"2017-06-19 08:14:56"
+            },
+            {
+                "archive": 0,
+                "created_at": u"2017-06-19 08:14:56",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 106,
+                "name_cn": u"修改",
+                "name_en": u"put",
+                "pid": 12,
+                "sequence": 11202,
+                "type": u"action",
+                "updated_at": u"2017-06-19 08:14:56"
+            },
+            {
+                "archive": 0,
+                "created_at": u"2017-06-19 08:14:56",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 107,
+                "name_cn": u"新增",
+                "name_en": u"post",
+                "pid": 12,
+                "sequence": 11203,
+                "type": u"action",
+                "updated_at": u"2017-06-19 08:14:56"
+            },
+            {
+                "archive": 0,
+                "created_at": u"2017-06-19 08:14:56",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 108,
+                "name_cn": u"删除",
+                "name_en": u"delete",
+                "pid": 12,
+                "sequence": 11204,
+                "type": u"action",
+                "updated_at": u"2017-06-19 08:14:56"
+            },
+            {
+                "archive": 0,
+                "created_at": u"2017-06-19 08:15:22",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 109,
+                "name_cn": u"查看",
+                "name_en": u"get",
+                "pid": 13,
+                "sequence": 11301,
+                "type": u"action",
+                "updated_at": u"2017-06-19 08:15:22"
+            },
+            {
+                "archive": 0,
+                "created_at": u"2017-06-19 08:15:22",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 110,
+                "name_cn": u"修改",
+                "name_en": u"put",
+                "pid": 13,
+                "sequence": 11302,
+                "type": u"action",
+                "updated_at": u"2017-06-19 08:15:22"
+            },
+            {
+                "archive": 0,
+                "created_at": u"2017-06-19 08:15:22",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 111,
+                "name_cn": u"新增",
+                "name_en": u"post",
+                "pid": 13,
+                "sequence": 11303,
+                "type": u"action",
+                "updated_at": u"2017-06-19 08:15:22"
+            },
+            {
+                "archive": 0,
+                "created_at": u"2017-06-19 08:15:22",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 112,
+                "name_cn": u"删除",
+                "name_en": u"delete",
+                "pid": 13,
+                "sequence": 11304,
+                "type": u"action",
+                "updated_at": u"2017-06-19 08:15:22"
+            },
+            {
+                "archive": 0,
+                "created_at": u"2017-06-19 08:15:40",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 113,
+                "name_cn": u"查看",
+                "name_en": u"get",
+                "pid": 14,
+                "sequence": 11401,
+                "type": u"action",
+                "updated_at": u"2017-06-19 08:15:40"
+            },
+            {
+                "archive": 0,
+                "created_at": u"2017-06-19 08:15:40",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 114,
+                "name_cn": u"修改",
+                "name_en": u"put",
+                "pid": 14,
+                "sequence": 11402,
+                "type": u"action",
+                "updated_at": u"2017-06-19 08:15:40"
+            },
+            {
+                "archive": 0,
+                "created_at": u"2017-06-19 08:15:40",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 115,
+                "name_cn": u"新增",
+                "name_en": u"post",
+                "pid": 14,
+                "sequence": 11403,
+                "type": u"action",
+                "updated_at": u"2017-06-19 08:15:40"
+            },
+            {
+                "archive": 0,
+                "created_at": u"2017-06-19 08:15:40",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 116,
+                "name_cn": u"删除",
+                "name_en": u"delete",
+                "pid": 14,
+                "sequence": 11404,
+                "type": u"action",
+                "updated_at": u"2017-06-19 08:15:40"
+            },
+            {
+                "archive": 0,
+                "created_at": u"2017-06-19 08:16:21",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 117,
+                "name_cn": u"查看",
+                "name_en": u"get",
+                "pid": 15,
+                "sequence": 11501,
+                "type": u"action",
+                "updated_at": u"2017-06-19 08:16:21"
+            },
+            {
+                "archive": 0,
+                "created_at": u"2017-06-19 08:16:21",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 118,
+                "name_cn": u"修改",
+                "name_en": u"put",
+                "pid": 15,
+                "sequence": 11502,
+                "type": u"action",
+                "updated_at": u"2017-06-19 08:16:21"
+            },
+            {
+                "archive": 0,
+                "created_at": u"2017-06-19 08:16:21",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 119,
+                "name_cn": u"新增",
+                "name_en": u"post",
+                "pid": 15,
+                "sequence": 11503,
+                "type": u"action",
+                "updated_at": u"2017-06-19 08:16:21"
+            },
+            {
+                "archive": 0,
+                "created_at": u"2017-06-19 08:16:21",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 120,
+                "name_cn": u"删除",
+                "name_en": u"delete",
+                "pid": 15,
+                "sequence": 11504,
+                "type": u"action",
+                "updated_at": u"2017-06-19 08:16:21"
+            },
+            {
+                "archive": 0,
+                "created_at": u"2017-06-19 08:16:42",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 121,
+                "name_cn": u"查看",
+                "name_en": u"get",
+                "pid": 16,
+                "sequence": 11601,
+                "type": u"action",
+                "updated_at": u"2017-06-19 08:16:42"
+            },
+            {
+                "archive": 0,
+                "created_at": u"2017-06-19 08:16:42",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 122,
+                "name_cn": u"修改",
+                "name_en": u"put",
+                "pid": 16,
+                "sequence": 11602,
+                "type": u"action",
+                "updated_at": u"2017-06-19 08:16:42"
+            },
+            {
+                "archive": 0,
+                "created_at": u"2017-06-19 08:16:42",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 123,
+                "name_cn": u"新增",
+                "name_en": u"post",
+                "pid": 16,
+                "sequence": 11603,
+                "type": u"action",
+                "updated_at": u"2017-06-19 08:16:42"
+            },
+            {
+                "archive": 0,
+                "created_at": u"2017-06-19 08:16:42",
+                "url": u"xx.yy.zz",
+                "visible": 1,
+                "icon": u"leaf",
+                "id": 124,
+                "name_cn": u"删除",
+                "name_en": u"delete",
+                "pid": 16,
+                "sequence": 11604,
+                "type": u"action",
+                "updated_at": u"2017-06-19 08:16:42"
+            }
+        ]
+        for asscess_data in access_list:
+            access = MenuModel(
+                    id=asscess_data['id'],
+                    name_cn=asscess_data['name_cn'],
+                    name_en=asscess_data['name_en'],
+                    pid=asscess_data['pid'],
+                    type=asscess_data['type'],
+                    sequence=asscess_data['sequence'],
+                    archive=asscess_data['archive'],
+                    icon=asscess_data['icon'],
+                    url=asscess_data['url'],
+                    visible=asscess_data['visible']
+            )
+            access.save()
+
+class TestUser:
+    user_data_login = deepcopy(user_data_login)
+    def test_add(self):
+
+        self.user_data_login['password'] = generate_password_hash(user_data_login['password'])
+        user = UserModel(**self.user_data_login)
+        user.save()
+
+        # class TestUser:
+        #     """User tests."""
+        #
+        #     def test_get_by_id(self):
+        #         """Get user by ID."""
+        #         user = Foo(username='wushuiyongoooo', email='wushuiyong@mail.com')
+        #         user.save()
+        #
+        #         retrieved = User.get_by_id(user.id)
+        #         assert retrieved == user
+
+        # def test_created_at_defaults_to_datetime(self):
+        #     """Test creation date."""
+        #     user = User(username='foo', email='foo@bar.com')
+        #     user.save()
+        #     assert bool(user.created_at)
+        #     assert isinstance(user.created_at, dt.datetime)
+        #
+        # def test_password_is_nullable(self):
+        #     """Test null password."""
+        #     user = User(username='foo', email='foo@bar.com')
+        #     user.save()
+        #     assert user.password is None
+        #
+        # def test_factory(self, db):
+        #     """Test user factory."""
+        #     user = UserFactory(password='myprecious')
+        #     db.session.commit()
+        #     assert bool(user.username)
+        #     assert bool(user.email)
+        #     assert bool(user.created_at)
+        #     assert user.is_admin is False
+        #     assert user.active is True
+        #     assert user.check_password('myprecious')
+        #
+        # def test_check_password(self):
+        #     """Check password."""
+        #     user = User.create(username='foo', email='foo@bar.com',
+        #                        password='foobarbaz123')
+        #     assert user.check_password('foobarbaz123') is True
+        #     assert user.check_password('barfoobaz') is False
+        #
+        # def test_full_name(self):
+        #     """User full name."""
+        #     user = UserFactory(first_name='Foo', last_name='Bar')
+        #     assert user.full_name == 'Foo Bar'
+        #
+        # def test_roles(self):
+        #     """Add a role to a user."""
+        #     role = Role(name='admin')
+        #     role.save()
+        #     user = UserFactory()
+        #     user.roles.append(role)
+        #     user.save()
+        #     assert role in user.roles

+ 33 - 0
tests/test_00_login.py

@@ -0,0 +1,33 @@
+# -*- coding: utf-8 -*-
+"""Test Apis."""
+import urllib
+
+import pytest
+
+from utils import *
+from walle.model.user import UserModel
+from copy import deepcopy
+from test_00_base import user_data_login
+
+@pytest.mark.usefixtures('db')
+class TestApiPassport:
+    """api role testing"""
+    uri_prefix = '/api/passport'
+
+    user_id = {}
+
+    user_data = deepcopy(user_data_login)
+
+
+    def test_fetch(self):
+        u = UserModel.get_by_id(1)
+
+    def test_login(self, user, testapp, client, db):
+        """create successful."""
+
+        resp = client.post('%s/login' % (self.uri_prefix), data=self.user_data)
+
+        response_success(resp)
+
+        del self.user_data['password']
+        compare_req_resp(self.user_data, resp)

+ 125 - 0
tests/test_01_api_environment.py

@@ -0,0 +1,125 @@
+# -*- coding: utf-8 -*-
+"""Test Apis."""
+from flask import json
+import types
+import urllib
+import pytest
+from utils import *
+
+
+@pytest.mark.usefixtures('db')
+class TestApiEnv:
+
+    """api role testing"""
+    uri_prefix = '/api/environment'
+
+    env_id = {}
+
+    env_data = {
+        'env_name': u'测试环境',
+    }
+
+    user_name_2 = u'Production'
+
+    env_data_2 = {
+        'env_name': u'Production',
+    }
+
+    env_data_remove = {
+        'env_name': u'environment_remove',
+    }
+
+    def test_create(self, user, testapp, client, db):
+        """create successful."""
+        # 1.create another role
+        resp = client.post('%s/' % (self.uri_prefix), data=self.env_data)
+
+        response_success(resp)
+        compare_req_resp(self.env_data, resp)
+
+        self.env_data['id'] = resp_json(resp)['data']['id']
+
+        # f.write(str(self.env_data))
+        # f.write(str(resp_json(resp)['data']['id']))
+
+
+        # 2.create another role
+        resp = client.post('%s/' % (self.uri_prefix), data=self.env_data_2)
+
+        response_success(resp)
+        compare_req_resp(self.env_data_2, resp)
+
+        self.env_data_2['id'] = resp_json(resp)['data']['id']
+
+    def test_one(self, user, testapp, client, db):
+        """item successful."""
+        # Goes to homepage
+
+        resp = client.get('%s/%d' % (self.uri_prefix, self.env_data['id']))
+
+        response_success(resp)
+        compare_req_resp(self.env_data, resp)
+
+    def test_get_list_page_size(self, user, testapp, client):
+        """test list should create 2 users at least, due to test pagination, searching."""
+
+        query = {
+            'page': 1,
+            'size': 1,
+        }
+        response = {
+            'count': 2,
+        }
+        resp = client.get('%s/?%s' % (self.uri_prefix, urllib.urlencode(query)))
+        response_success(resp)
+        resp_dict = resp_json(resp)
+
+        compare_in(self.env_data_2, resp_dict['data']['list'].pop())
+        compare_req_resp(response, resp)
+
+    def test_get_list_query(self, user, testapp, client):
+        """test list should create 2 users at least, due to test pagination, searching."""
+        query = {
+            'page': 1,
+            'size': 1,
+            'kw': self.user_name_2
+        }
+        response = {
+            'count': 1,
+        }
+        resp = client.get('%s/?%s' % (self.uri_prefix, urllib.urlencode(query)))
+        response_success(resp)
+        resp_dict = resp_json(resp)
+
+        compare_in(self.env_data_2, resp_dict['data']['list'].pop())
+        compare_req_resp(response, resp)
+
+    def test_get_update(self, user, testapp, client):
+        """Login successful."""
+        # 1.update
+        env_data_2 = self.env_data_2
+        env_data_2['env_name'] = 'Tester_edit'
+        resp = client.put('%s/%d' % (self.uri_prefix, self.env_data_2['id']), data=env_data_2)
+
+        response_success(resp)
+        compare_req_resp(env_data_2, resp)
+
+        # 3.get it
+        resp = client.get('%s/%d' % (self.uri_prefix, self.env_data_2['id']))
+        response_success(resp)
+        compare_req_resp(env_data_2, resp)
+
+    def test_get_remove(self, user, testapp, client):
+        """Login successful."""
+        # 1.create another role
+        resp = client.post('%s/' % (self.uri_prefix), data=self.env_data_remove)
+        env_id = resp_json(resp)['data']['id']
+        response_success(resp)
+
+        # 2.delete
+        resp = client.delete('%s/%d' % (self.uri_prefix, env_id))
+        response_success(resp)
+
+        # 3.get it
+        resp = client.get('%s/%d' % (self.uri_prefix, env_id))
+        response_error(resp)

+ 119 - 0
tests/test_02_api_role.py

@@ -0,0 +1,119 @@
+# -*- coding: utf-8 -*-
+"""Test Apis."""
+from flask import json
+import types
+import urllib
+import pytest
+from utils import *
+
+
+@pytest.mark.usefixtures('db')
+class TestApiRole:
+    """api role testing"""
+    uri_prefix = '/api/role'
+
+    # role_data = {
+    #     'role_name': u'研发组',
+    #     'access_ids': '1,3',
+    # }
+    #
+    # role_name_2 = u'Test Leader'
+    #
+    # role_data_2 = {
+    #     'role_name': u'Test Leader',
+    #     'access_ids': '1,2',
+    # }
+
+    # def test_create(self, user, testapp, client, db):
+    #     """create successful."""
+    #     # 1.create another role
+    #     resp = client.post('%s/' % (self.uri_prefix), data=self.role_data)
+    #
+    #     response_success(resp)
+    #     compare_req_resp(self.role_data, resp)
+    #     self.role_data['id'] = resp_json(resp)['data']['id']
+    #
+    #     # 2.create another role
+    #     resp = client.post('%s/' % (self.uri_prefix), data=self.role_data_2)
+    #     self.role_data_2['id'] = resp_json(resp)['data']['id']
+    #
+    #
+    #     response_success(resp)
+    #     compare_req_resp(self.role_data_2, resp)
+
+    # def test_one(self, user, testapp, client, db):
+    #     """item successful."""
+    #     # Goes to homepage
+    #     resp = client.get('%s/master' % (self.uri_prefix, self.role_data['id']))
+    #
+    #     response_success(resp)
+    #     compare_req_resp(self.role_data, resp)
+
+    def test_get_list_page_size(self, user, testapp, client):
+        """test list should create 2 users at least, due to test pagination, searching."""
+
+        query = {
+            'page': 1,
+            'size': 1,
+        }
+        response = {
+            'count': 5,
+        }
+        resp = client.get('%s/' % (self.uri_prefix))
+        response_success(resp)
+
+        compare_req_resp(response, resp)
+    #
+    # def test_get_list_query(self, user, testapp, client):
+    #     """test list should create 2 users at least, due to test pagination, searching."""
+    #     query = {
+    #         'page': 1,
+    #         'size': 1,
+    #         'kw': self.role_name_2
+    #     }
+    #     response = {
+    #         'count': 1,
+    #     }
+    #     resp = client.get('%s/?%s' % (self.uri_prefix, urllib.urlencode(query)))
+    #     response_success(resp)
+    #     resp_dict = resp_json(resp)
+    #
+    #     compare_in(self.role_data_2, resp_dict['data']['list'].pop())
+    #     compare_req_resp(response, resp)
+
+    # def test_get_update(self, user, testapp, client):
+    #     """Login successful."""
+    #     # 1.create another role
+    #     # resp = client.post('%s/' % (self.uri_prefix), data=self.role_data)
+    #     # role_id = resp_json(resp)['data']['id']
+    #     #
+    #     # response_success(resp)
+    #     # compare_req_resp(self.role_data, resp)
+    #
+    #     # 2.update
+    #     resp = client.put('%s/%d' % (self.uri_prefix, self.role_data_2['id']), data=self.role_data_2)
+    #
+    #     response_success(resp)
+    #     compare_req_resp(self.role_data_2, resp)
+    #
+    #     # 3.get it
+    #     resp = client.get('%s/%d' % (self.uri_prefix, self.role_data_2['id']))
+    #     response_success(resp)
+    #     compare_req_resp(self.role_data_2, resp)
+    #
+    # def test_get_remove(self, user, testapp, client):
+    #     """Login successful."""
+    #     # 1.create another role
+    #     another_role = self.role_data_2
+    #     another_role['role_name'] = u'To Be Removed'
+    #     resp = client.post('%s/' % (self.uri_prefix), data=another_role)
+    #     role_id = resp_json(resp)['data']['id']
+    #
+    #     response_success(resp)
+    #
+    #     # 2.delete
+    #     resp = client.delete('%s/%d' % (self.uri_prefix, role_id))
+    #
+    #     # 3.get it
+    #     resp = client.get('%s/%d' % (self.uri_prefix, role_id))
+    #     response_error(resp)

+ 163 - 0
tests/test_03_api_user.py

@@ -0,0 +1,163 @@
+# -*- coding: utf-8 -*-
+"""Test Apis."""
+import urllib
+
+import pytest
+from flask import current_app
+from utils import *
+
+user_data = {
+    'email': u'test01@walle-web.io',
+    'password': u'Walle987',
+    'username': u'测试用户',
+}
+
+
+@pytest.mark.usefixtures('db')
+class TestApiUser:
+    """api role testing"""
+    uri_prefix = '/api/user'
+
+    user_id = {}
+
+    user_data = user_data
+
+    user_name_2 = u'Tester'
+
+    user_data_error = {
+        'email': u'user_error@walle-web.io',
+        'password': u'walle99',
+        'username': u'Tester',
+    }
+
+    user_data_2 = {
+        'email': u'test02@walle-web.io',
+        'password': u'Walle99999',
+        'username': u'Tester',
+    }
+
+    user_data_3 = {
+        'email': u'test03@walle-web.io',
+        'password': u'Walle99999',
+        'username': u'waller03',
+    }
+
+    user_data_4 = {
+        'email': u'test04@walle-web.io',
+        'password': u'Walle99999',
+        'username': u'waller04',
+    }
+
+    user_data_remove = {
+        'email': u'test_remove@walle-web.io',
+        'password': u'Walle987&^*',
+        'username': u'test_remove',
+    }
+
+    def test_create(self, user, testapp, client, db):
+        """create successful."""
+
+        # 1.error
+        resp = client.post('%s/' % (self.uri_prefix), data=self.user_data_error)
+        response_error(resp)
+
+        # 2.create another user
+        resp = client.post('%s/' % (self.uri_prefix), data=self.user_data)
+
+        response_success(resp)
+
+        del self.user_data['password']
+        compare_req_resp(self.user_data, resp)
+        self.user_data['id'] = resp_json(resp)['data']['id']
+
+        # 3.create another user
+        resp = client.post('%s/' % (self.uri_prefix), data=self.user_data_2)
+
+        response_success(resp)
+        del self.user_data_2['password']
+
+        compare_req_resp(self.user_data_2, resp)
+        self.user_data_2['id'] = resp_json(resp)['data']['id']
+
+        # 4.create another user
+        resp = client.post('%s/' % (self.uri_prefix), data=self.user_data_3)
+        del self.user_data_3['password']
+
+        # 5.create another user
+        resp = client.post('%s/' % (self.uri_prefix), data=self.user_data_4)
+        del self.user_data_4['password']
+
+    def test_one(self, user, testapp, client, db):
+        """item successful."""
+        # Goes to homepage
+
+        resp = client.get('%s/%d' % (self.uri_prefix, self.user_data['id']))
+
+        response_success(resp)
+        compare_req_resp(self.user_data, resp)
+
+    def test_get_list_page_size(self, user, testapp, client):
+        """test list should create 2 users at least, due to test pagination, searching."""
+
+        query = {
+            'page': 1,
+            'size': 1,
+        }
+        response = {
+            'count': 6,
+        }
+        resp = client.get('%s/?%s' % (self.uri_prefix, urllib.urlencode(query)))
+        response_success(resp)
+        resp_dict = resp_json(resp)
+
+        compare_in(self.user_data_4, resp_dict['data']['list'].pop())
+        compare_req_resp(response, resp)
+
+    def test_get_list_query(self, user, testapp, client):
+        """test list should create 2 users at least, due to test pagination, searching."""
+        query = {
+            'page': 1,
+            'size': 1,
+            'kw': self.user_name_2
+        }
+        response = {
+            'count': 1,
+        }
+        resp = client.get('%s/?%s' % (self.uri_prefix, urllib.urlencode(query)))
+        response_success(resp)
+        resp_dict = resp_json(resp)
+
+        compare_in(self.user_data_2, resp_dict['data']['list'].pop())
+        compare_req_resp(response, resp)
+
+    def test_get_update(self, user, testapp, client):
+        """Login successful."""
+
+        # 2.update
+        user_data_2 = self.user_data_2
+        user_data_2['username'] = 'Tester_edit'
+        current_app.logger.error(user_data_2)
+        resp = client.put('%s/%d' % (self.uri_prefix, self.user_data_2['id']), data=user_data_2)
+
+        response_success(resp)
+        compare_req_resp(user_data_2, resp)
+
+        # 3.get it
+        resp = client.get('%s/%d' % (self.uri_prefix, self.user_data_2['id']))
+        response_success(resp)
+        compare_req_resp(user_data_2, resp)
+
+    def test_get_remove(self, user, testapp, client):
+        """Login successful."""
+        # 1.create another role
+        resp = client.post('%s/' % (self.uri_prefix), data=self.user_data_remove)
+        user_id = resp_json(resp)['data']['id']
+        response_success(resp)
+
+        # 2.delete
+        resp = client.delete('%s/%d' % (self.uri_prefix, user_id))
+        response_success(resp)
+
+        # 3.get it
+        resp = client.get('%s/%d' % (self.uri_prefix, user_id))
+        response_error(resp)

+ 44 - 0
tests/test_04_api_passport.py

@@ -0,0 +1,44 @@
+# -*- coding: utf-8 -*-
+"""Test Apis."""
+from flask import json
+import types
+import urllib
+import pytest
+from utils import *
+from test_03_api_user import user_data
+from test_00_base import user_data_login
+from copy import deepcopy
+
+@pytest.mark.usefixtures('db')
+class TestApiPassport:
+    """api role testing"""
+    uri_prefix = '/api/passport'
+
+    user_id = {}
+
+    user_data = user_data
+    user_data_login = deepcopy(user_data_login)
+
+    user_name = u'test01@walle-web.io'
+
+    def test_login(self, user, testapp, client, db):
+        """create successful."""
+        # 1.create another role
+        query = {
+            'page': 1,
+            'size': 1,
+            'kw': self.user_name
+        }
+        response = {
+            'count': 1,
+        }
+        resp = client.get('/api/user/?%s' % (urllib.urlencode(query)))
+        response_success(resp)
+        compare_req_resp(response, resp)
+
+        resp = client.post('%s/login' % (self.uri_prefix), data=self.user_data_login)
+
+        response_success(resp)
+
+        del self.user_data_login['password']
+        compare_req_resp(self.user_data_login, resp)

+ 150 - 0
tests/test_05_api_space.py

@@ -0,0 +1,150 @@
+# -*- coding: utf-8 -*-
+"""Test Apis."""
+import pytest
+from flask import current_app
+from utils import *
+
+
+@pytest.mark.usefixtures('db')
+class TestApiSpace:
+    """api role testing"""
+    uri_prefix = '/api/space'
+
+    user_id = {}
+
+    space_data = {
+        'name': u'大数据',
+        'user_id': u'1',
+        'members': json.dumps([{"user_id": 1, "role": "MASTER"}, {"user_id": 2, "role": "DEVELOPER"}, {"user_id": 3, "role": "DEVELOPER"}]),
+    }
+
+    space_name_2 = u'瓦力'
+
+    space_data_2 = {
+        'name': u'瓦力',
+        'user_id': u'2',
+        'members': json.dumps([{"user_id": 1, "role": "MASTER"}, {"user_id": 2, "role": "DEVELOPER"}, {"user_id": 4, "role": "DEVELOPER"}]),
+    }
+
+    space_data_remove = {
+        'name': u'瓦尔登',
+        'user_id': u'2',
+        'members': json.dumps([{"user_id": 1, "role": "MASTER"}, {"user_id": 2, "role": "DEVELOPER"}]),
+    }
+
+    def test_create(self, user, testapp, client, db):
+
+        """create successful."""
+        # 1.create project
+        resp = client.post('%s/' % (self.uri_prefix), data=self.space_data)
+
+        response_success(resp)
+        # compare_req_resp(self.space_data, resp)
+        current_app.logger.info(resp_json(resp)['data'])
+        self.compare_member_req_resp(self.space_data, resp)
+        self.space_data['space_id'] = resp_json(resp)['data']['id']
+
+        """create successful."""
+        # 1.create another project
+        resp = client.post('%s/' % (self.uri_prefix), data=self.space_data_2)
+        response_success(resp)
+
+        self.compare_member_req_resp(self.space_data_2, resp)
+        self.space_data_2['space_id'] = resp_json(resp)['data']['id']
+
+        # 2.create another space
+        # resp = client.post('%s/' % (self.uri_prefix), data=self.space_data_2)
+        # space_data_2 = self.get_list_ids(self.space_data_2)
+        #
+        # response_success(resp)
+        # compare_req_resp(space_data_2, resp)
+        # self.space_data_2['space_id'] = resp_json(resp)['data']['space_id']
+
+    def test_one(self, user, testapp, client, db):
+        """item successful."""
+        resp = client.get('%s/%d' % (self.uri_prefix, self.space_data['space_id']))
+
+        response_success(resp)
+        self.compare_member_req_resp(self.space_data, resp)
+
+        # def test_get_list_page_size(self, user, testapp, client):
+        #     """test list should create 2 users at least, due to test pagination, searching."""
+        #
+        #     query = {
+        #         'page': 1,
+        #         'size': 1,
+        #     }
+        #     response = {
+        #         'count': 2,
+        #     }
+        #     resp = client.get('%s/?%s' % (self.uri_prefix, urllib.urlencode(query)))
+        #     response_success(resp)
+        #     resp_dict = resp_json(resp)
+        #
+        #     res = resp_dict['data']['list'].pop()
+        #     # f.write(str(res))
+        #
+        #     # compare_in(self.space_data_2, resp_dict['data']['list'].pop())
+        #     space_data_2 = self.get_list_ids(self.space_data_2)
+        #     del space_data_2['user_id']
+        #
+        #     compare_in(space_data_2, res)
+        #     compare_req_resp(response, resp)
+        #
+        # def test_get_list_query(self, user, testapp, client):
+        #     """test list should create 2 users at least, due to test pagination, searching."""
+        #     query = {
+        #         'page': 1,
+        #         'size': 1,
+        #         'kw': self.space_name_2
+        #     }
+        #     response = {
+        #         'count': 1,
+        #     }
+        #     resp = client.get('%s/?%s' % (self.uri_prefix, urllib.urlencode(query)))
+        #     response_success(resp)
+        #     resp_dict = resp_json(resp)
+        #     space_data_2 = self.get_list_ids(self.space_data_2)
+        #     del space_data_2['user_id']
+        #
+        #     compare_in(space_data_2, resp_dict['data']['list'].pop())
+        #     compare_req_resp(response, resp)
+        #
+
+    def test_get_update(self, user, testapp, client):
+        """Login successful."""
+        # 1.update
+        space_data_2 = self.space_data_2
+        space_data_2['name'] = u'瓦力2.0'
+        resp = client.put('%s/%d' % (self.uri_prefix, self.space_data_2['space_id']), data=space_data_2)
+
+        response_success(resp)
+        self.compare_member_req_resp(self.space_data_2, resp)
+
+        # 2.get it
+        resp = client.get('%s/%d' % (self.uri_prefix, self.space_data_2['space_id']))
+        response_success(resp)
+
+        response_success(resp)
+        self.compare_member_req_resp(self.space_data_2, resp)
+
+        # def test_get_remove(self, user, testapp, client):
+        #     """Login successful."""
+        #     # 1.create another role
+        #     resp = client.post('%s/' % (self.uri_prefix), data=self.space_data_remove)
+        #     space_id = resp_json(resp)['data']['space_id']
+        #     response_success(resp)
+        #
+        #     # 2.delete
+        #     resp = client.delete('%s/%d' % (self.uri_prefix, space_id))
+        #     response_success(resp)
+        #
+        #     # 3.get it
+        #     resp = client.get('%s/%d' % (self.uri_prefix, space_id))
+        #     response_error(resp)
+
+    def compare_member_req_resp(self, request, response):
+        for user_response in resp_json(response)['data']['members']:
+            for user_request in json.loads(request['members']):
+                if user_request['user_id'] == user_response['user_id']:
+                    assert user_request['role'] == user_response['role']

+ 128 - 0
tests/test_06_api_server.py

@@ -0,0 +1,128 @@
+# -*- coding: utf-8 -*-
+"""Test Apis."""
+from flask import json
+import types
+import urllib
+import pytest
+from utils import *
+
+
+@pytest.mark.usefixtures('db')
+class TestApiServer:
+    """api role testing"""
+    uri_prefix = '/api/server'
+
+    server_id = {}
+
+    server_data = {
+        'name': u'开发机01',
+        'host': u'127.0.0.1',
+    }
+
+    # should be equal to server_data_2.name
+    server_name_2 = u'test02'
+
+    server_data_2 = {
+        'name': u'test02',
+        'host': u'192.168.0.1',
+    }
+
+    server_data_remove = {
+        'name': u'this server will be deleted soon',
+        'host': u'11.22.33.44',
+    }
+
+    def test_create(self, user, testapp, client, db):
+        """create successful."""
+        # 1.create another role
+        resp = client.post('%s/' % (self.uri_prefix), data=self.server_data)
+
+        response_success(resp)
+        compare_req_resp(self.server_data, resp)
+
+        self.server_data['id'] = resp_json(resp)['data']['id']
+
+        # f.write(str(self.server_data))
+        # f.write(str(resp_json(resp)['data']['id']))
+
+
+        # 2.create another role
+        resp = client.post('%s/' % (self.uri_prefix), data=self.server_data_2)
+
+        response_success(resp)
+        compare_req_resp(self.server_data_2, resp)
+
+        self.server_data_2['id'] = resp_json(resp)['data']['id']
+
+    def test_one(self, user, testapp, client, db):
+        """item successful."""
+        # Goes to homepage
+
+        resp = client.get('%s/%d' % (self.uri_prefix, self.server_data['id']))
+
+        response_success(resp)
+        compare_req_resp(self.server_data, resp)
+
+    def test_get_list_page_size(self, user, testapp, client):
+        """test list should create 2 users at least, due to test pagination, searching."""
+
+        query = {
+            'page': 1,
+            'size': 1,
+        }
+        response = {
+            'count': 2,
+        }
+        resp = client.get('%s/?%s' % (self.uri_prefix, urllib.urlencode(query)))
+        response_success(resp)
+        resp_dict = resp_json(resp)
+
+        compare_in(self.server_data_2, resp_dict['data']['list'].pop())
+        compare_req_resp(response, resp)
+
+    def test_get_list_query(self, user, testapp, client):
+        """test list should create 2 users at least, due to test pagination, searching."""
+        query = {
+            'page': 1,
+            'size': 1,
+            'kw': self.server_name_2
+        }
+        response = {
+            'count': 1,
+        }
+        resp = client.get('%s/?%s' % (self.uri_prefix, urllib.urlencode(query)))
+        response_success(resp)
+        resp_dict = resp_json(resp)
+
+        compare_in(self.server_data_2, resp_dict['data']['list'].pop())
+        compare_req_resp(response, resp)
+
+    def test_get_update(self, user, testapp, client):
+        """Login successful."""
+        # 1.update
+        server_data_2 = self.server_data_2
+        server_data_2['name'] = 'Tester_edit'
+        resp = client.put('%s/%d' % (self.uri_prefix, self.server_data_2['id']), data=server_data_2)
+
+        response_success(resp)
+        compare_req_resp(server_data_2, resp)
+
+        # 3.get it
+        resp = client.get('%s/%d' % (self.uri_prefix, self.server_data_2['id']))
+        response_success(resp)
+        compare_req_resp(server_data_2, resp)
+
+    def test_get_remove(self, user, testapp, client):
+        """Login successful."""
+        # 1.create another role
+        resp = client.post('%s/' % (self.uri_prefix), data=self.server_data_remove)
+        server_id = resp_json(resp)['data']['id']
+        response_success(resp)
+
+        # 2.delete
+        resp = client.delete('%s/%d' % (self.uri_prefix, server_id))
+        response_success(resp)
+
+        # 3.get it
+        resp = client.get('%s/%d' % (self.uri_prefix, server_id))
+        response_error(resp)

+ 258 - 0
tests/test_07_api_project.py

@@ -0,0 +1,258 @@
+# -*- coding: utf-8 -*-
+"""Test Apis."""
+import urllib
+import json
+import pytest
+from flask import current_app
+from utils import *
+
+
+@pytest.mark.usefixtures('db')
+class TestApiProject:
+    """api role testing"""
+    uri_prefix = '/api/project'
+
+    server_id = {}
+
+    project_data = {
+        "environment_id": 1,
+        "space_id": 1,
+        "excludes": u"*.log",
+        "keep_version_num": 11,
+        "name": u"walden-瓦尔登",
+        "post_deploy": u"echo post_deploy",
+        "post_release": u"echo post_release",
+        "prev_deploy": u"echo prev_deploy",
+        "prev_release": u"echo prev_release",
+        "repo_mode": u"branch",
+        "repo_password": u"",
+        "repo_url": u"git@github.com:meolu/walle-web.git",
+        "repo_username": u"",
+        "server_ids": u"1,2",
+        "target_releases": u"/tmp/walle/library",
+        "target_root": u"/tmp/walle/root",
+        "target_user": u"work",
+        "target_port": u"22",
+        "task_vars": u"debug=1;\\napp=auotapp.py",
+        "user_id": 1,
+    }
+
+    project_data_members = [
+        {"user_id": 1, "role": "MASTER"},
+        {"user_id": 2, "role": "DEVELOPER"}
+    ]
+
+    # should be equal to project_data_2.name
+    project_name_2 = u'walle-web'
+
+    project_data_2 = {
+        "environment_id": 2,
+        "space_id": 1,
+        "excludes": u"*.log",
+        "keep_version_num": 10,
+        "name": u"walle-web",
+        "post_deploy": u"echo post_deploy",
+        "post_release": u"echo post_release",
+        "prev_deploy": u"echo prev_deploy",
+        "prev_release": u"echo prev_release",
+        "repo_mode": u"branch",
+        "repo_password": u"",
+        "repo_url": u"git@github.com:meolu/walle-web.git",
+        "repo_username": u"",
+        "server_ids": u"1,2",
+        "target_releases": u"/tmp/walle/library",
+        "target_root": u"/tmp/walle/root",
+        "target_user": u"work",
+        "target_port": u"22",
+        "task_vars": u"debug=1;\\napp=auotapp.py",
+        "user_id": 1
+    }
+
+    project_data_2_update = {
+        "environment_id": 1,
+        "space_id": 1,
+        "excludes": u"*.log",
+        "keep_version_num": 11,
+        "name": u"walle-web to walden edit",
+        "post_deploy": u"echo post_deploy; pwd",
+        "post_release": u"echo post_release; pwd",
+        "prev_deploy": u"echo prev_deploy; pwd",
+        "prev_release": u"echo prev_release; pwd",
+        "repo_mode": u"tag",
+        "repo_password": u"",
+        "repo_url": u"git@github.com:meolu/walden.git",
+        "repo_username": u"",
+        "server_ids": u"1,2",
+        "target_releases": u"/tmp/walden/library",
+        "target_root": u"/tmp/walden/root",
+        "target_user": u"work",
+        "target_port": u"22",
+        "task_vars": u"debug=1;\\napp=auotapp.py; project=walden",
+        "user_id": 1
+    }
+
+    project_data_remove = {
+        'name': u'this server will be deleted soon',
+        "environment_id": 1,
+        "space_id": 1,
+        "excludes": u"*.log",
+        "keep_version_num": 11,
+        "post_deploy": u"echo post_deploy",
+        "post_release": u"echo post_release",
+        "prev_deploy": u"echo prev_deploy",
+        "prev_release": u"echo prev_release",
+        "repo_mode": u"branch",
+        "repo_password": u"",
+        "repo_url": u"git@github.com:meolu/walle-web.git",
+        "repo_username": u"",
+        "server_ids": u"1,2",
+        "target_releases": u"/tmp/walle/library",
+        "target_root": u"/tmp/walle/root",
+        "target_user": u"work",
+        "target_port": u"22",
+        "task_vars": u"debug=1;\\napp=auotapp.py",
+        "user_id": 1
+    }
+
+    def test_create(self, user, testapp, client, db):
+        """create successful."""
+        # 1. create another project
+        project_data = dict(self.project_data, **dict({'members': json.dumps(self.project_data_members)}))
+        resp = client.post('%s/' % (self.uri_prefix), data=project_data)
+        response_success(resp)
+
+        self.project_compare_req_resp(self.project_data, resp)
+        self.project_data['id'] = resp_json(resp)['data']['id']
+
+        # 2. create another project
+        resp = client.post('%s/' % (self.uri_prefix), data=self.project_data_2)
+
+        response_success(resp)
+        self.project_compare_req_resp(self.project_data_2, resp)
+        self.project_data_2['id'] = resp_json(resp)['data']['id']
+
+    def test_one(self, user, testapp, client, db):
+        """item successful."""
+        # Goes to homepage
+
+        resp = client.get('%s/%d' % (self.uri_prefix, self.project_data['id']))
+
+        response_success(resp)
+        self.project_compare_req_resp(self.project_data, resp)
+
+    def test_get_list_page_size(self, user, testapp, client):
+        """test list should create 2 users at least, due to test pagination, searching."""
+
+        query = {
+            'page': 1,
+            'size': 1,
+        }
+        response = {
+            'count': 2,
+        }
+        resp = client.get('%s/?%s' % (self.uri_prefix, urllib.urlencode(query)))
+        response_success(resp)
+        resp_dict = resp_json(resp)
+
+        self.project_compare_in(self.project_data_2, resp_dict['data']['list'].pop())
+        self.project_compare_req_resp(response, resp)
+
+    def test_get_list_query(self, user, testapp, client):
+        """test list should create 2 users at least, due to test pagination, searching."""
+        query = {
+            'page': 1,
+            'size': 1,
+            'kw': self.project_name_2
+        }
+        response = {
+            'count': 1,
+        }
+        resp = client.get('%s/?%s' % (self.uri_prefix, urllib.urlencode(query)))
+        response_success(resp)
+        resp_dict = resp_json(resp)
+
+        self.project_compare_in(self.project_data_2, resp_dict['data']['list'].pop())
+        self.project_compare_req_resp(response, resp)
+
+    def test_get_update(self, user, testapp, client):
+        """Login successful."""
+        # 1.update
+        resp = client.put('%s/%d' % (self.uri_prefix, self.project_data_2['id']), data=self.project_data_2_update)
+
+        response_success(resp)
+        self.project_compare_req_resp(self.project_data_2_update, resp)
+
+        # 3.get it
+        resp = client.get('%s/%d' % (self.uri_prefix, self.project_data_2['id']))
+        response_success(resp)
+        self.project_compare_req_resp(self.project_data_2_update, resp)
+
+    def test_get_update_members(self, user, testapp, client):
+        """Login successful."""
+
+        # 1.1 create user group
+        headers = {'content-type': 'application/json'}
+        resp = client.put('%s/%d/members' % (self.uri_prefix, self.project_data_2['id']), data=json.dumps(self.project_data_members), headers=headers)
+        current_app.logger.info(resp)
+
+        response_success(resp)
+        current_app.logger.info(resp_json(resp)['data'])
+        self.project_data_2_update['members'] = json.dumps(self.project_data_members)
+        self.compare_member_req_resp(self.project_data_2_update, resp)
+
+        # # 1.update
+        # resp = client.put('%s/%d/members' % (self.uri_prefix, self.project_data_2['id']), data=self.project_data_2_update)
+        #
+        # response_success(resp)
+        # self.project_compare_req_resp(self.project_data_2_update, resp)
+
+        # 3.get it
+        resp = client.get('%s/%d' % (self.uri_prefix, self.project_data_2['id']))
+        response_success(resp)
+        self.compare_member_req_resp(self.project_data_2_update, resp)
+
+    def test_get_remove(self, user, testapp, client):
+        """Login successful."""
+        # 1.create another role
+        resp = client.post('%s/' % (self.uri_prefix), data=self.project_data_remove)
+        project_id = resp_json(resp)['data']['id']
+        response_success(resp)
+
+        # 2.delete
+        resp = client.delete('%s/%d' % (self.uri_prefix, project_id))
+        response_success(resp)
+
+        # 3.get it
+        resp = client.get('%s/%d' % (self.uri_prefix, project_id))
+        response_error(resp)
+
+    def get_list_ids(self, projectOrigin):
+        group_list = projectOrigin.copy()
+        group_list['user_ids'] = map(int, projectOrigin['user_ids'].split(','))
+        return group_list
+
+    def project_compare_req_resp(self, req_obj, resp):
+        """
+        there is some thing difference in project api
+        such as server_ids
+        :param resp:
+        :return:
+        """
+        resp_obj = resp_json(resp)['data']
+        servers = []
+        if resp_obj.has_key('server_info'):
+            for server in resp_obj['server_info']:
+                servers.append(str(server['id']))
+
+        self.project_compare_in(req_obj, resp_obj)
+
+    def project_compare_in(self, req_obj, resp_obj):
+        for k, v in req_obj.items():
+            assert k in resp_obj.keys(), 'Key %r not in response (keys are %r)' % (k, resp_obj.keys())
+            assert resp_obj[k] == v, 'Value for key %r should be %r but is %r' % (k, v, resp_obj[k])
+
+    def compare_member_req_resp(self, request, response):
+        for user_response in resp_json(response)['data']['members']:
+            for user_request in json.loads(request['members']):
+                if user_request['user_id'] == user_response['user_id']:
+                    assert user_request['role'] == user_response['role']

+ 165 - 0
tests/test_08_api_task.py

@@ -0,0 +1,165 @@
+# -*- coding: utf-8 -*-
+"""Test Apis."""
+from flask import json
+import types
+import urllib
+import pytest
+from utils import *
+from walle.model.deploy import TaskModel
+
+class TestApiTask:
+    """api role testing"""
+    uri_prefix = '/api/task'
+
+    server_id = {}
+
+    # TODO 需要再准备一个是否需要开启审核的status单测
+
+    task_data = {
+        'name': u'提交一个测试上线单',
+        'project_id': 1,
+        'servers': u'127.0.0.1,192.168.0.1',
+        'commit_id': u'a89eb23c',
+        'branch': u'master',
+        'file_transmission_mode': 0,
+        'file_list': u'*.log'
+    }
+
+    # should be equal to task_data_2.name
+    task_name_2 = u'The Second bill'
+
+    task_data_2 = {
+        'name': u'The Second Bill',
+        'project_id': 1,
+        'servers': u'1,2',
+        'commit_id': u'a89eb23c',
+        'branch': u'master',
+        'file_transmission_mode': 0,
+        'file_list': u'*.log'
+    }
+
+    task_data_2_update = {
+        'name': u'The Second Bill Edit',
+        'project_id': 1,
+        'servers': u'1,2',
+        'commit_id': u'a89eb23c',
+        'branch': u'master',
+        'file_transmission_mode': 0,
+        'file_list': u'*.log,*.txt'
+    }
+
+    task_data_remove = {
+        'name': u'A Task To Be Removed',
+        'project_id': 1,
+        'servers': u'1,2,3',
+        'commit_id': u'a89eb23c',
+        'branch': u'master',
+        'file_transmission_mode': 0,
+        'file_list': u'*.log'
+    }
+
+    def test_create(self, user, testapp, client, db):
+        """create successful."""
+        # 1.create another role
+        resp = client.post('%s/' % (self.uri_prefix), data=self.task_data)
+
+        response_success(resp)
+        compare_req_resp(self.task_data, resp)
+
+        self.task_data['id'] = resp_json(resp)['data']['id']
+
+        # 2.create another role
+        resp = client.post('%s/' % (self.uri_prefix), data=self.task_data_2)
+
+        response_success(resp)
+        compare_req_resp(self.task_data_2, resp)
+
+        self.task_data_2['id'] = resp_json(resp)['data']['id']
+
+    def test_one(self, user, testapp, client, db):
+        """item successful."""
+        # Goes to homepage
+
+        resp = client.get('%s/%d' % (self.uri_prefix, self.task_data['id']))
+
+        response_success(resp)
+        compare_req_resp(self.task_data, resp)
+
+    def test_get_list_page_size(self, user, testapp, client):
+        """test list should create 2 users at least, due to test pagination, searching."""
+
+        query = {
+            'page': 1,
+            'size': 1,
+        }
+        response = {
+            'count': 2,
+        }
+        resp = client.get('%s/?%s' % (self.uri_prefix, urllib.urlencode(query)))
+        response_success(resp)
+        resp_dict = resp_json(resp)
+
+        compare_in(self.task_data_2, resp_dict['data']['list'].pop())
+        compare_req_resp(response, resp)
+
+    def test_get_list_query(self, user, testapp, client):
+        """test list should create 2 users at least, due to test pagination, searching."""
+        query = {
+            'page': 1,
+            'size': 1,
+            'kw': self.task_name_2
+        }
+        response = {
+            'count': 1,
+        }
+        resp = client.get('%s/?%s' % (self.uri_prefix, urllib.urlencode(query)))
+        response_success(resp)
+        resp_dict = resp_json(resp)
+
+        compare_in(self.task_data_2, resp_dict['data']['list'].pop())
+        compare_req_resp(response, resp)
+
+    def test_get_update(self, user, testapp, client):
+        """Login successful."""
+        # 1.update
+        resp = client.put('%s/%d' % (self.uri_prefix, self.task_data_2['id']), data=self.task_data_2_update)
+
+        response_success(resp)
+        compare_req_resp(self.task_data_2_update, resp)
+
+        # 3.get it
+        resp = client.get('%s/%d' % (self.uri_prefix, self.task_data_2['id']))
+        response_success(resp)
+        compare_req_resp(self.task_data_2_update, resp)
+
+    def test_get_update_audit(self, user, testapp, client):
+        """Login successful."""
+        # 1.update
+        resp = client.put('%s/%d/audit' % (self.uri_prefix, self.task_data_2['id']))
+
+        response_success(resp)
+        assert resp_json(resp)['data']['status'] == TaskModel.status_pass
+
+    def test_get_update_reject(self, user, testapp, client):
+        """Login successful."""
+        # 1.update
+        resp = client.put('%s/%d/reject' % (self.uri_prefix, self.task_data_2['id']))
+
+        response_success(resp)
+        assert resp_json(resp)['data']['status'] == TaskModel.status_reject
+
+
+    def test_get_remove(self, user, testapp, client):
+        """Login successful."""
+        # 1.create another role
+        resp = client.post('%s/' % (self.uri_prefix), data=self.task_data_remove)
+        server_id = resp_json(resp)['data']['id']
+        response_success(resp)
+
+        # 2.delete
+        resp = client.delete('%s/%d' % (self.uri_prefix, server_id))
+        response_success(resp)
+
+        # 3.get it
+        resp = client.get('%s/%d' % (self.uri_prefix, server_id))
+        response_error(resp)

+ 29 - 0
tests/test_config.py

@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+"""Test configs."""
+from walle.app import create_app
+from walle.config.settings_dev import DevConfig
+from walle.config.settings_test import TestConfig
+from walle.config.settings_prod import ProdConfig
+
+
+# def test_production_config():
+#     """Production config."""
+#     app = create_app(ProdConfig)
+#     assert app.config['ENV'] == 'prod'
+#     assert app.config['DEBUG'] is False
+#     assert app.config['DEBUG_TB_ENABLED'] is False
+#     assert app.config['ASSETS_DEBUG'] is False
+
+
+# def test_dev_config():
+#     """Development config."""
+#     app = create_app(DevConfig)
+#     assert app.config['ENV'] == 'dev'
+#     assert app.config['DEBUG'] is True
+#     assert app.config['ASSETS_DEBUG'] is True
+
+def test_test_config():
+    """Development config."""
+    app = create_app(TestConfig)
+    assert app.config['TESTING'] == True
+    assert app.config['DEBUG'] is True

+ 70 - 0
tests/test_forms.py

@@ -0,0 +1,70 @@
+# -*- coding: utf-8 -*-
+"""Test forms."""
+
+from walle.form.user import LoginForm
+
+#
+# class TestRegisterForm:
+#     """Register form."""
+#
+#     def test_validate_user_already_registered(self, user):
+#         """Enter username that is already registered."""
+#         form = RegisterForm(username=user.username, email='foo@bar.com',
+#                             password='example', confirm='example')
+#
+#         assert form.validate() is False
+#         assert 'Username already registered' in form.username.errors
+#
+#     def test_validate_email_already_registered(self, user):
+#         """Enter email that is already registered."""
+#         form = RegisterForm(username='unique', email=user.email,
+#                             password='example', confirm='example')
+#
+#         assert form.validate() is False
+#         assert 'Email already registered' in form.email.errors
+#
+#     def test_validate_success(self, db):
+#         """Register with success."""
+#         form = RegisterForm(username='newusername', email='new@test.test',
+#                             password='example', confirm='example')
+#         assert form.validate() is True
+# from test_03_api_user import  TestApiUser
+
+class TestLoginForm:
+    """Login form."""
+
+    def test_validate_success(self, user):
+        """Login successful."""
+        # test_api_user = TestApiUser()
+        # user.set_password(password='doitn87ow*&*')
+        # user.save()
+        # form = LoginForm(email=u'test01@walle-web.io', password=u'walle987&^*')
+        # form.validate()
+        pass
+
+        # assert form.validate() is True
+
+    # def test_validate_unknown_username(self, db):
+    #     """Unknown username."""
+    #     form = LoginForm(username='unknown', password='example')
+    #     assert form.validate() is False
+    #     assert 'Unknown username' in form.username.errors
+    #     assert form.user is None
+    #
+    # def test_validate_invalid_password(self, user):
+    #     """Invalid password."""
+    #     user.set_password('example')
+    #     user.save()
+    #     form = LoginForm(username=user.username, password='wrongpassword')
+    #     assert form.validate() is False
+    #     assert 'Invalid password' in form.password.errors
+    #
+    # def test_validate_inactive_user(self, user):
+    #     """Inactive user."""
+    #     user.active = False
+    #     user.set_password('example')
+    #     user.save()
+    #     # Correct username and password, but user is not activated
+    #     form = LoginForm(username=user.username, password='example')
+    #     assert form.validate() is False
+    #     assert 'User not activated' in form.username.errors

+ 120 - 0
tests/test_functional.py

@@ -0,0 +1,120 @@
+# -*- coding: utf-8 -*-
+"""Functional tests using WebTest.
+
+See: http://webtest.readthedocs.org/
+"""
+from flask import url_for
+
+from walle.model.user import UserModel
+
+from .factories import UserFactory
+
+
+class TestLoggingIn:
+    """Login."""
+
+    def test_can_log_in_returns_200(self, user, testapp, client):
+        """Login successful."""
+        # Goes to homepage
+        res = client.get('/')
+        # # Fills out login form in navbar
+        # form = res.forms['loginForm']
+        # form['username'] = user.username
+        # form['password'] = 'myprecious'
+        # # Submits
+        # res = form.submit().follow()
+        assert res.status_code == 200
+
+#     def test_sees_alert_on_log_out(self, user, testapp):
+#         """Show alert on logout."""
+#         res = testapp.get('/')
+#         # Fills out login form in navbar
+#         form = res.forms['loginForm']
+#         form['username'] = user.username
+#         form['password'] = 'myprecious'
+#         # Submits
+#         res = form.submit().follow()
+#         res = testapp.get(url_for('public.logout')).follow()
+#         # sees alert
+#         assert 'You are logged out.' in res
+#
+#     def test_sees_error_message_if_password_is_incorrect(self, user, testapp):
+#         """Show error if password is incorrect."""
+#         # Goes to homepage
+#         res = testapp.get('/')
+#         # Fills out login form, password incorrect
+#         form = res.forms['loginForm']
+#         form['username'] = user.username
+#         form['password'] = 'wrong'
+#         # Submits
+#         res = form.submit()
+#         # sees error
+#         assert 'Invalid password' in res
+#
+#     def test_sees_error_message_if_username_doesnt_exist(self, user, testapp):
+#         """Show error if username doesn't exist."""
+#         # Goes to homepage
+#         res = testapp.get('/')
+#         # Fills out login form, password incorrect
+#         form = res.forms['loginForm']
+#         form['username'] = 'unknown'
+#         form['password'] = 'myprecious'
+#         # Submits
+#         res = form.submit()
+#         # sees error
+#         assert 'Unknown user' in res
+#
+#
+# class TestRegistering:
+#     """Register a user."""
+#
+#     def test_can_register(self, user, testapp):
+#         """Register a new user."""
+#         old_count = len(User.query.all())
+#         # Goes to homepage
+#         res = testapp.get('/')
+#         # Clicks Create Account button
+#         res = res.click('Create account')
+#         # Fills out the form
+#         form = res.forms['registerForm']
+#         form['username'] = 'foobar'
+#         form['email'] = 'foo@bar.com'
+#         form['password'] = 'secret'
+#         form['confirm'] = 'secret'
+#         # Submits
+#         res = form.submit().follow()
+#         assert res.status_code == 200
+#         # A new user was created
+#         assert len(User.query.all()) == old_count + 1
+#
+#     def test_sees_error_message_if_passwords_dont_match(self, user, testapp):
+#         """Show error if passwords don't match."""
+#         # Goes to registration page
+#         res = testapp.get(url_for('public.register'))
+#         # Fills out form, but passwords don't match
+#         form = res.forms['registerForm']
+#         form['username'] = 'foobar'
+#         form['email'] = 'foo@bar.com'
+#         form['password'] = 'secret'
+#         form['confirm'] = 'secrets'
+#         # Submits
+#         res = form.submit()
+#         # sees error message
+#         assert 'Passwords must match' in res
+#
+#     def test_sees_error_message_if_user_already_registered(self, user, testapp):
+#         """Show error if user already registered."""
+#         user = UserFactory(active=True)  # A registered user
+#         user.save()
+#         # Goes to registration page
+#         res = testapp.get(url_for('public.register'))
+#         # Fills out form, but username is already registered
+#         form = res.forms['registerForm']
+#         form['username'] = user.username
+#         form['email'] = 'foo@bar.com'
+#         form['password'] = 'secret'
+#         form['confirm'] = 'secret'
+#         # Submits
+#         res = form.submit()
+#         # sees error
+#         assert 'Username already registered' in res

+ 86 - 0
tests/test_models.py

@@ -0,0 +1,86 @@
+# -*- coding: utf-8 -*-
+"""Model unit tests."""
+import datetime as dt
+
+import pytest
+
+from walle.model.user import RoleModel
+from walle.model.user import UserModel
+from walle.model.deploy import EnvironmentModel
+from .factories import UserFactory
+
+
+@pytest.mark.usefixtures('db')
+class TestFoo:
+    """User tests."""
+
+    def test_get_by_id(self):
+        """Get user by ID."""
+        pass
+        # user = Foo(username='testuser', email='wushuiyong@mail.com')
+        # user.save()
+        # print(user.id)
+        #
+        # retrieved = Foo.get_by_id(user.id)
+        # assert retrieved == user
+
+class TestEnvironment:
+    def test_add(self):
+        env_new = EnvironmentModel()
+        env_id = env_new.add(env_name=u'开发环境')
+
+# class TestUser:
+#     """User tests."""
+#
+#     def test_get_by_id(self):
+#         """Get user by ID."""
+#         user = Foo(username='wushuiyongoooo', email='wushuiyong@mail.com')
+#         user.save()
+#
+#         retrieved = User.get_by_id(user.id)
+#         assert retrieved == user
+
+    # def test_created_at_defaults_to_datetime(self):
+    #     """Test creation date."""
+    #     user = User(username='foo', email='foo@bar.com')
+    #     user.save()
+    #     assert bool(user.created_at)
+    #     assert isinstance(user.created_at, dt.datetime)
+    #
+    # def test_password_is_nullable(self):
+    #     """Test null password."""
+    #     user = User(username='foo', email='foo@bar.com')
+    #     user.save()
+    #     assert user.password is None
+    #
+    # def test_factory(self, db):
+    #     """Test user factory."""
+    #     user = UserFactory(password='myprecious')
+    #     db.session.commit()
+    #     assert bool(user.username)
+    #     assert bool(user.email)
+    #     assert bool(user.created_at)
+    #     assert user.is_admin is False
+    #     assert user.active is True
+    #     assert user.check_password('myprecious')
+    #
+    # def test_check_password(self):
+    #     """Check password."""
+    #     user = User.create(username='foo', email='foo@bar.com',
+    #                        password='foobarbaz123')
+    #     assert user.check_password('foobarbaz123') is True
+    #     assert user.check_password('barfoobaz') is False
+    #
+    # def test_full_name(self):
+    #     """User full name."""
+    #     user = UserFactory(first_name='Foo', last_name='Bar')
+    #     assert user.full_name == 'Foo Bar'
+    #
+    # def test_roles(self):
+    #     """Add a role to a user."""
+    #     role = Role(name='admin')
+    #     role.save()
+    #     user = UserFactory()
+    #     user.roles.append(role)
+    #     user.save()
+    #     assert role in user.roles

+ 37 - 0
tests/utils.py

@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+"""
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2017-05-20 22:25:27
+    :author: wushuiyong@walle-web.io
+"""
+import json
+
+
+def response_success(response):
+    assert 200 <= response.status_code < 300, 'Received %d response: %s' % (response.status_code, response.data)
+    resp = resp_json(response)
+    assert resp['code'] == 0, 'Received %d response: %s' % (resp['code'], response.data)
+
+
+def response_error(response, code=None):
+    assert 200 <= response.status_code < 300, 'Received %d response: %s' % (response.status_code, response.data)
+    resp = resp_json(response)
+    assert resp['code'] != 0, 'Received %d response: %s' % (resp['code'], response.data)
+
+
+def compare_req_resp(req_obj, resp):
+    resp_obj = resp_json(resp)['data']
+
+    compare_in(req_obj, resp_obj)
+
+
+def compare_in(req_obj, resp_obj):
+    for k, v in req_obj.items():
+        assert k in resp_obj.keys(), 'Key %r not in response (keys are %r)' % (k, resp_obj.keys())
+        assert resp_obj[k] == v, 'Value for key %r should be %r but is %r' % (k, v, resp_obj[k])
+
+
+def resp_json(resp):
+    return json.loads(resp.get_data(as_text=True))

+ 11 - 0
travis.test.sh

@@ -0,0 +1,11 @@
+#!/usr/bin/env zsh
+###################################################################
+# @Author: wushuiyong
+# @Created Time : 五  8/24 13:05:27 2018
+#
+# @File Name: travis.test.sh
+# @Description:
+###################################################################
+git add .travis.yml
+git commit -m'test travis-ci'
+git push origin development

+ 1 - 0
walle/__init__.py

@@ -0,0 +1 @@
+"""Main application package."""

+ 8 - 0
walle/api/__init__.py

@@ -0,0 +1,8 @@
+# -*- coding: utf-8 -*-
+"""
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2017-06-14 15:25:50
+    :author: wushuiyong@walle-web.io
+"""

+ 109 - 0
walle/api/access.py

@@ -0,0 +1,109 @@
+# -*- coding: utf-8 -*-
+"""
+
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2017-03-25 11:15:01
+    :author: wushuiyong@walle-web.io
+"""
+
+from flask import request
+
+from walle.api.api import SecurityResource
+from walle.model.user import MenuModel
+from walle.model.user import RoleModel
+
+
+class AccessAPI(SecurityResource):
+    controller = 'access'
+
+    """
+    权限是以resource + method作为一个access
+
+    """
+
+    def get(self, access_id=None):
+        """
+        fetch access list or one access
+
+        :return:
+        """
+        super(AccessAPI, self).get()
+        return self.item(access_id) if access_id else self.list()
+
+    def list(self):
+        """
+        fetch access list
+        /access/
+
+        :return:
+        """
+
+        access_model = MenuModel()
+        access_list = access_model.list()
+        return self.render_json(data=access_list)
+
+    def item(self, access_id):
+        """
+        /access/<int:access_id>
+
+        :param access_id:
+        :return:
+        """
+        access_info = RoleModel().list(size=1000)
+        data = MenuModel.query.all()
+        list = [p.to_json() for p in data]
+        return self.render_json(data=list)
+
+    def post(self):
+        """
+        新增角色
+        /access/
+
+        :return:
+        """
+        super(AccessAPI, self).post()
+
+        access_name = request.form.get('access_name', None)
+        access_permissions_ids = request.form.get('access_ids', '')
+        access_model = RoleModel()
+        access_id = access_model.add(name=access_name, access_ids=access_permissions_ids)
+
+        if not access_id:
+            self.render_json(code=-1)
+        return self.render_json(data=access_model.item())
+
+    def put(self, access_id):
+        """
+        修改角色
+        /access/<int:access_id>
+
+        :param access_id:
+        :return:
+        """
+        super(AccessAPI, self).put()
+
+        access_name = request.form.get('access_name', None)
+        access_ids = request.form.get('access_ids', '')
+
+        if not access_name:
+            return self.render_json(code=-1, message='access_name can not be empty')
+
+        access_model = RoleModel(id=access_id)
+        ret = access_model.update(name=access_name, access_ids=access_ids)
+        return self.render_json(data=access_model.item())
+
+    def delete(self, access_id):
+        """
+        删除一个角色
+        /access/<int:access_id>
+
+        :return:
+        """
+        super(AccessAPI, self).delete()
+
+        access_model = RoleModel(id=access_id)
+        ret = access_model.remove()
+
+        return self.render_json(code=0)

+ 137 - 0
walle/api/api.py

@@ -0,0 +1,137 @@
+# -*- coding: utf-8 -*-
+"""
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2017-06-14 16:00:23
+    :author: wushuiyong@walle-web.io
+"""
+
+from flask import jsonify, abort, current_app, request
+from flask_restful import Resource
+from walle.service.rbac.access import Access as AccessRbac
+from functools import wraps
+from walle.service.code import Code
+from flask import current_app, session
+from flask_login import current_user
+
+class ApiResource(Resource):
+    module = None
+    controller = None
+    actions = None
+    action = None
+
+    # TODO 权限验证
+    def __init__(self):
+        pass
+
+    @staticmethod
+    def render_json(code=0, message='', data=[]):
+        return ApiResource.json(code=code, message=message, data=data)
+
+    @staticmethod
+    def json(code=0, message=None, data=[]):
+        if not Code.code_msg.has_key(code):
+            current_app.logger.error('unkown code %s' % (code))
+
+        if Code.code_msg.has_key(code) and not message:
+            message = Code.code_msg[code]
+
+        return jsonify({
+            'code': code,
+            'message': message,
+            'data': data,
+        })
+
+    @staticmethod
+    def list_json(list, count, table={}, code=0, message='', enable_create=False):
+        return ApiResource.render_json(data={'list': list, 'count': count, 'table': table, 'enable_create': enable_create},
+                                       code=code,
+                                       message=message)
+
+
+class SecurityResource(ApiResource):
+    module = None
+    controller = None
+    action = None
+
+    # @login_required
+    def get(self, *args, **kwargs):
+        self.action = 'get'
+        current_app.logger.info('========= SecurityResource =======')
+
+
+        return self.validator()
+
+    # @login_required
+    def delete(self, *args, **kwargs):
+        self.action = 'delete'
+        is_allow = AccessRbac.is_allow(action=self.action, controller=self.controller)
+        if not is_allow:
+            self.render_json(code=403, message=u'无操作权限')
+            # abort(403)
+            pass
+        pass
+
+    # @login_required
+    def put(self, *args, **kwargs):
+        self.action = 'put'
+        is_allow = AccessRbac.is_allow(action=self.action, controller=self.controller)
+        if not is_allow:
+            self.render_json(code=403, message=u'无操作权限')
+            # abort(403)
+            pass
+        pass
+
+    # @login_required
+    def post(self, *args, **kwargs):
+        """
+        # @login_required
+        :param args:
+        :param kwargs:
+        :return:
+        """
+        self.action = 'post'
+        return self.validator()
+
+    def validator(self):
+        if not AccessRbac.is_login():
+            return self.render_json(code=1000, message=u'请先登录')
+
+        if not AccessRbac.is_allow(action=self.action, controller=self.controller):
+            return self.render_json(code=1001, message=u'无操作权限')
+
+
+    @staticmethod
+    def is_super(func):
+        @wraps(func)
+        def is_enable(*args, **kwargs):
+            if current_user.role_info.name <> 'super':
+                return ApiResource.render_json(code=403, message=u'无操作权限')
+            current_app.logger.info("user is login: %s" % (current_user.is_authenticated))
+            current_app.logger.info("args: %s kwargs: %s" % (args, kwargs))
+            return func(*args, **kwargs)
+
+        return is_enable
+
+    @staticmethod
+    def is_master(func):
+        @wraps(func)
+        def is_enable(*args, **kwargs):
+            if current_user.role_info.name not in ['super', 'master']:
+                return ApiResource.render_json(code=403, message=u'无操作权限')
+            current_app.logger.info("user is login: %s" % (current_user.is_authenticated))
+            current_app.logger.info("args: %s kwargs: %s" % (args, kwargs))
+            return func(*args, **kwargs)
+
+        return is_enable
+
+
+class Base(Resource):
+    def get(self):
+        """
+        fetch role list or one role
+
+        :return:
+        """
+        return 'walle-web 2.0'

+ 58 - 0
walle/api/deploy.py

@@ -0,0 +1,58 @@
+# -*- coding: utf-8 -*-
+"""
+
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2017-03-25 11:15:01
+    :author: wushuiyong@walle-web.io
+"""
+
+from flask import request
+from walle.api.api import SecurityResource
+from walle.model.deploy import TaskRecordModel
+from walle.service.deployer import Deployer
+from walle.service.websocket import WSHandler
+
+
+class DeployAPI(SecurityResource):
+    def get(self, task_id=None):
+        """
+        fetch environment list or one item
+        /environment/<int:env_id>
+
+        :return:
+        """
+        super(DeployAPI, self).get()
+
+    # def get(self, method):
+    #     """
+    #     fetch role list or one role
+    #
+    #     :return:
+    #     """
+    #     if method == 'menu':
+    #         return self.menu()
+    #     elif method == 'mail':
+    #         return self.mail()
+    #     elif method == 'walle':
+    #         return self.walless()
+
+    def post(self):
+        """
+        fetch role list or one role
+
+        :return:
+        """
+        super(DeployAPI, self).post()
+
+        task_id = request.form['task_id']
+        if not task_id or not task_id.isdigit():
+            return self.render_json(code=-1)
+        wi = Deployer(task_id, websocket=WSHandler)
+        ret = wi.walle_deploy()
+        record = TaskRecordModel().fetch(task_id)
+        return self.render_json(data={
+            'command': '',
+            'record': record,
+        })

+ 119 - 0
walle/api/environment.py

@@ -0,0 +1,119 @@
+# -*- coding: utf-8 -*-
+"""
+
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2017-03-25 11:15:01
+    :author: wushuiyong@walle-web.io
+"""
+
+from flask import request
+from walle.form.environment import EnvironmentForm
+from walle.model.deploy import EnvironmentModel
+from walle.api.api import SecurityResource
+
+
+class EnvironmentAPI(SecurityResource):
+
+    controller = 'environment'
+
+    def get(self, env_id=None):
+        """
+        fetch environment list or one item
+        /environment/<int:env_id>
+
+        :return:
+        """
+        super(EnvironmentAPI, self).get()
+
+        return self.item(env_id) if env_id else self.list()
+
+    def list(self):
+        """
+        fetch environment list
+
+        :return:
+        """
+
+        page = int(request.args.get('page', 0))
+        page = page - 1 if page else 0
+        size = float(request.args.get('size', 10))
+        kw = request.values.get('kw', '')
+
+        table = [
+            {
+                'key': 'tag',
+                'value': ['线上', '测试'],
+                'sort': 0
+            }
+        ]
+
+        env_model = EnvironmentModel()
+        env_list, count = env_model.list(page=page, size=size, kw=kw)
+        return self.list_json(list=env_list, count=count, table=table)
+
+    def item(self, env_id):
+        """
+        获取某个用户组
+
+        :param env_id:
+        :return:
+        """
+
+        env_model = EnvironmentModel(id=env_id)
+        env_info = env_model.item()
+        if not env_info:
+            return self.render_json(code=-1)
+        return self.render_json(data=env_info)
+
+    def post(self):
+        """
+        create a environment
+        /environment/
+
+        :return:
+        """
+        super(EnvironmentAPI, self).post()
+
+        form = EnvironmentForm(request.form, csrf_enabled=False)
+        if form.validate_on_submit():
+            env_new = EnvironmentModel()
+            env_id = env_new.add(env_name=form.env_name.data)
+            if not env_id:
+                return self.render_json(code=-1)
+            return self.render_json(data=env_new.item())
+        else:
+            return self.render_json(code=-1, message=form.errors)
+
+    def put(self, env_id):
+        """
+        update environment
+        /environment/<int:env_id>
+
+        :return:
+        """
+        super(EnvironmentAPI, self).put()
+
+        form = EnvironmentForm(request.form, csrf_enabled=False)
+        form.set_env_id(env_id)
+        if form.validate_on_submit():
+            env = EnvironmentModel(id=env_id)
+            ret = env.update(env_name=form.env_name.data, status=form.status.data)
+            return self.render_json(data=env.item())
+        else:
+            return self.render_json(code=-1, message=form.errors)
+
+    def delete(self, env_id):
+        """
+        remove an environment
+        /environment/<int:env_id>
+
+        :return:
+        """
+        super(EnvironmentAPI, self).delete()
+
+        env_model = EnvironmentModel(id=env_id)
+        env_model.remove(env_id)
+
+        return self.render_json(message='')

+ 88 - 0
walle/api/general.py

@@ -0,0 +1,88 @@
+# -*- coding: utf-8 -*-
+"""
+
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2017-03-25 11:15:01
+    :author: wushuiyong@walle-web.io
+"""
+
+import os
+from flask import request, abort, session, current_app
+from flask_login import current_user, login_required
+from walle.api.api import SecurityResource
+from walle.model.deploy import TaskRecordModel
+from walle.model.user import MenuModel
+from walle.model.user import UserModel
+from walle.service import emails
+from walle.service.deployer import Deployer
+from walle.service.rbac.role import *
+from werkzeug.utils import secure_filename
+from walle.service.extensions import permission
+
+
+class GeneralAPI(SecurityResource):
+    actions = ['menu', 'websocket']
+
+    @permission.gte_develop_or_uid
+    def get(self, action):
+        """
+        fetch role list or one role
+
+        :return:
+        """
+
+        if action in self.actions:
+            self_action = getattr(self, action.lower(), None)
+            return self_action()
+        else:
+            abort(404)
+
+    def post(self, action):
+        """
+        fetch role list or one role
+
+        :return:
+        """
+        if action == 'avatar':
+            return self.avater()
+
+    def menu(self):
+        role = 10
+        user = UserModel(id=current_user.id).item()
+        menu = MenuModel().menu(role=role)
+        space = {
+            'current': '',
+            'available': '',
+        }
+        # TODO
+        # 超管不需要展示空间列表
+        if current_user.role <> SUPER:
+            space = {
+                'current': session['space_info'],
+                'available': session['space_list'],
+            }
+        data = {
+            'user': user,
+            'menu': menu,
+            'space': space,
+        }
+        return self.render_json(data=data)
+
+    def mail(self):
+        ret = emails.send_email('wushuiyong@renrenche.com', 'email from service@walle-web.io', 'xxxxxxx', 'yyyyyyy')
+        return self.render_json(data={
+            'avatar': 'emails.send_email',
+            'done': ret,
+        })
+
+    def websocket(self, task_id=None):
+        task_id = 12
+        wi = Deployer(task_id)
+        ret = wi.walle_deploy()
+        record = TaskRecordModel().fetch(task_id)
+        return self.render_json(data={
+            'command': ret,
+            'record': record,
+        })

+ 162 - 0
walle/api/group.py

@@ -0,0 +1,162 @@
+# -*- coding: utf-8 -*-
+"""
+
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2017-03-25 11:15:01
+    :author: wushuiyong@walle-web.io
+"""
+from flask_login import login_required, current_user
+from flask import request
+from walle.form.group import GroupForm
+from walle.model.user import MemberModel, UserModel
+from walle.model.user import SpaceModel
+from walle.model.tag import TagModel
+from walle.api.api import SecurityResource
+from flask import current_app
+from walle.service.rbac.role import *
+
+class GroupAPI(SecurityResource):
+
+    def get(self, group_id=None):
+        """
+        用户组列表
+        /group/
+
+        :return:
+        """
+        super(GroupAPI, self).get()
+
+        return self.item(group_id) if group_id else self.list()
+
+    def list(self):
+        """
+        用户组列表
+        /group/
+
+        :return:
+        """
+        page = int(request.args.get('page', 0))
+        page = page - 1 if page else 0
+        size = float(request.args.get('size', 10))
+        kw = request.values.get('kw', '')
+        filter = {'name': {'like': kw}} if kw else {}
+        space_model = SpaceModel()
+        space_list, count = space_model.list(page=page, size=size, kw=kw)
+        return self.list_json(list=space_list, count=count, enable_create=permission.enable_role(OWNER))
+
+        group_model, count = SpaceModel().query_paginate(page=page, limit=size, filter_name_dict=filter)
+        groups = []
+        for group_info in group_model:
+            group_sub = MemberModel.query \
+                .filter_by(group_id=group_info.id) \
+                .count()
+
+            group_info = group_info.to_json()
+            group_info['users'] = group_sub
+            group_info['group_id'] = group_info['id']
+            group_info['group_name'] = group_info['name']
+            groups.append(group_info)
+        return self.list_json(list=groups, count=count)
+
+    def item(self, group_id):
+        """
+        获取某个用户组
+        /group/<int:group_id>
+
+        :param group_id:
+        :return:
+        """
+        ## sqlalchemy版本
+        group_model = MemberModel()
+        group = group_model.members(group_id=group_id)
+        if group:
+            return self.render_json(data=group)
+        return self.render_json(code=-1)
+
+        ## mixin 版本
+        group_model = TagModel().get_by_id(group_id)
+        if not group_model:
+            return self.render_json(code=-1)
+
+        user_model = UserModel()
+        user_info = user_model.fetch_by_uid(uids=group_model.users)
+
+        group_info = group_model.to_dict()
+        group_info['members'] = user_info
+        group_info['users'] = len(user_info)
+        group_info['group_name'] = group_info['name']
+        group_info['group_id'] = group_info['id']
+        return self.render_json(data=group_info)
+
+    def post(self):
+        """
+        create group
+        /group/
+
+        :return:
+        """
+        super(GroupAPI, self).post()
+        current_app.logger.info(request.form)
+        current_app.logger.info(request.form.user_ids)
+
+        form = GroupForm(request.form, csrf_enabled=False)
+        if form.validate_on_submit():
+            # user_ids = [int(uid) for uid in form.user_ids.data.split(',')]
+
+            group_id = 0
+            # group_new = MemberModel()
+            # group_id = group_new.add(group_name=form.group_name.data, user_ids=user_ids)
+            if not group_id:
+                return self.render_json(code=-1)
+            return self.render_json(data=group_new.item())
+        else:
+            return self.render_json(code=-1, message=form.errors)
+
+    def put(self, group_id):
+        """
+        update group
+        /group/<int:group_id>
+
+        :return:
+        """
+        super(GroupAPI, self).put()
+
+        form = GroupForm(request.form, csrf_enabled=False)
+        form.set_group_id(group_id)
+        if form.validate_on_submit():
+            # pass
+            # user_ids = [int(uid) for uid in form.user_ids.data.split(',')]
+            import json
+            current_app.logger.info(form.uid_roles)
+
+            current_app.logger.info(json.loads(form.uid_roles))
+
+            group_model = MemberModel(group_id=group_id)
+            for uid_role in json.loads(form.uid_roles):
+                uid_role['project_id'] = 0
+                current_app.logger.info(uid_role)
+                group_model.create_or_update(uid_role, uid_role)
+
+            # group_model.update(group_id=group_id,
+            #                    group_name=form.group_name.data,
+            #                    user_ids=user_ids)
+            return self.render_json(data=group_model.item())
+
+        return self.render_json(code=-1, message=form.errors)
+
+    def delete(self, group_id):
+        """
+        /group/<int:group_id>
+
+        :return:
+        """
+        super(GroupAPI, self).delete()
+
+        group_model = MemberModel()
+        tag_model = TagModel()
+        tag_model.remove(group_id)
+        group_model.remove(group_id)
+
+        return self.render_json(message='')

+ 57 - 0
walle/api/passport.py

@@ -0,0 +1,57 @@
+# -*- coding: utf-8 -*-
+"""
+
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2017-03-25 11:15:01
+    :author: wushuiyong@walle-web.io
+"""
+
+
+from flask import request, abort, current_app
+from flask_login import current_user
+from flask_login import login_user, logout_user
+from walle.api.api import ApiResource
+from walle.form.user import LoginForm
+from walle.model.user import UserModel
+
+
+class PassportAPI(ApiResource):
+    actions = ['login', 'logout']
+
+    def post(self, action):
+        """
+        user login
+        /passport/
+
+        :return:
+        """
+
+        if action in self.actions:
+            self_action = getattr(self, action.lower(), None)
+            return self_action()
+        else:
+            abort(404)
+
+    def login(self):
+        """
+        user login
+        /passport/
+
+        :return:
+        """
+        form = LoginForm(request.form, csrf_enabled=False)
+        if form.validate_on_submit():
+            user = UserModel.query.filter_by(email=form.email.data).first()
+
+            if user is not None and user.verify_password(form.password.data):
+                login_user(user)
+                user.fresh_session()
+                return self.render_json(data=current_user.to_json())
+
+        return self.render_json(code=-1, data=form.errors)
+
+    def logout(self):
+        logout_user()
+        return self.render_json()

+ 146 - 0
walle/api/project.py

@@ -0,0 +1,146 @@
+# -*- coding: utf-8 -*-
+"""
+
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2017-03-25 11:15:01
+    :author: wushuiyong@walle-web.io
+"""
+
+import json
+
+from flask import request, current_app
+from flask_login import login_required
+from walle.api.api import SecurityResource
+from walle.form.project import ProjectForm
+from walle.model.deploy import ProjectModel
+from walle.model.user import MemberModel
+from walle.service.rbac.role import *
+from walle.service.extensions import permission
+
+
+class ProjectAPI(SecurityResource):
+
+    @permission.gte_develop_or_uid
+    def get(self, action=None, project_id=None):
+        """
+        fetch project list or one item
+        /project/<int:project_id>
+
+        :return:
+        """
+        super(ProjectAPI, self).get()
+
+        return self.item(project_id) if project_id else self.list()
+
+    def list(self):
+        """
+        fetch project list
+
+        :return:
+        """
+        page = int(request.args.get('page', 0))
+        page = page - 1 if page else 0
+        size = float(request.args.get('size', 10))
+        kw = request.values.get('kw', '')
+        environment_id = request.values.get('environment_id', '')
+
+        project_model = ProjectModel()
+        space_id = None if current_user.role == SUPER else session['space_id']
+        project_list, count = project_model.list(page=page, size=size, kw=kw, environment_id=environment_id, space_id=space_id)
+        return self.list_json(list=project_list, count=count)
+
+    def item(self, project_id):
+        """
+        获取某个用户组
+
+        :param id:
+        :return:
+        """
+
+        project_model = ProjectModel(id=project_id)
+        project_info = project_model.item()
+        if not project_info:
+            return self.render_json(code=-1)
+
+        group_info = MemberModel().members(project_id=project_id)
+        current_app.logger.info(group_info)
+
+        return self.render_json(data=dict(project_info, **group_info))
+
+    def post(self):
+        """
+        create a project
+        /environment/
+
+        :return:
+        """
+        super(ProjectAPI, self).post()
+
+        form = ProjectForm(request.form, csrf_enabled=False)
+        if form.validate_on_submit():
+            project_new = ProjectModel()
+            data = form.form2dict()
+            id = project_new.add(data)
+            if not id:
+                return self.render_json(code=-1)
+
+            return self.render_json(data=project_new.item())
+        else:
+            return self.render_json(code=-1, message=form.errors)
+
+    def put(self, project_id, action=None):
+        """
+        update environment
+        /environment/<int:id>
+
+        :return:
+        """
+        super(ProjectAPI, self).put()
+
+        if action and action == 'members':
+            return self.members(project_id, members=json.loads(request.data))
+
+        form = ProjectForm(request.form, csrf_enabled=False)
+        form.set_id(project_id)
+        if form.validate_on_submit():
+            server = ProjectModel().get_by_id(project_id)
+            data = form.form2dict()
+            # a new type to update a model
+            ret = server.update(data)
+            return self.render_json(data=server.item())
+        else:
+            return self.render_json(code=-1, message=form.errors)
+
+    def delete(self, project_id):
+        """
+        remove an environment
+        /environment/<int:id>
+
+        :return:
+        """
+        super(ProjectAPI, self).delete()
+
+        project_model = ProjectModel(id=project_id)
+        project_model.remove(project_id)
+
+        return self.render_json(message='')
+
+    def members(self, project_id, members):
+        """
+
+        :param project_id:
+        :param members:
+        :return:
+        """
+        # TODO login for group id
+        group_id = 1
+
+        group_model = MemberModel(project_id=project_id)
+        ret = group_model.update_project(project_id=project_id, members=members)
+
+        item = group_model.members()
+
+        return self.render_json(data=item)
+

+ 75 - 0
walle/api/repo.py

@@ -0,0 +1,75 @@
+# -*- coding: utf-8 -*-
+"""
+
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2017-03-25 11:15:01
+    :author: wushuiyong@walle-web.io
+"""
+from flask import request, abort
+from walle.api.api import SecurityResource
+from walle.service.deployer import Deployer
+
+
+class RepoAPI(SecurityResource):
+    actions = ['tags', 'branches', 'commits']
+
+    def get(self, action, commit=None):
+        """
+        fetch project list or one item
+        /project/<int:project_id>
+
+        :return:
+        """
+        super(RepoAPI, self).get()
+        project_id = request.args.get('project_id', '')
+
+        if action in self.actions:
+            self_action = getattr(self, action.lower(), None)
+            return self_action(project_id=project_id)
+        else:
+            abort(404)
+
+
+    def tags(self, project_id=None):
+        """
+        fetch project list or one item
+        /tag/
+
+        :return:
+        """
+        wi = Deployer(project_id=project_id)
+        tag_list = wi.list_tag()
+        tags = tag_list.stdout.strip().split('\n')
+        return self.render_json(data={
+            'tags': tags,
+        })
+
+    def branches(self, project_id=None):
+        """
+        fetch project list or one item
+        /tag/
+
+        :return:
+        """
+        wi = Deployer(project_id=project_id)
+        branches = wi.list_branch()
+        return self.render_json(data={
+            'branches': branches,
+        })
+
+    def commits(self, project_id):
+        """
+        fetch project list or one item
+        /tag/
+
+        :return:
+        """
+        branch = request.args.get('branch', '')
+        wi = Deployer(project_id=project_id)
+        commits = wi.list_commit(branch)
+
+        return self.render_json(data={
+            'branches': commits,
+        })

+ 29 - 0
walle/api/role.py

@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+"""
+
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2017-03-25 11:15:01
+    :author: wushuiyong@walle-web.io
+"""
+
+from flask import request
+from walle.api.api import SecurityResource
+from walle.model.user import RoleModel
+
+
+class RoleAPI(SecurityResource):
+    """
+    角色模型跟gitlab一样,分别是超管,空间所有者,项目管理员,开发者,访客
+    """
+
+    def get(self):
+        """
+        fetch role list
+        /role/
+
+        :return:
+        """
+        role_list, count = RoleModel.list()
+        return self.list_json(list=role_list, count=count)

+ 109 - 0
walle/api/server.py

@@ -0,0 +1,109 @@
+# -*- coding: utf-8 -*-
+"""
+
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2017-03-25 11:15:01
+    :author: wushuiyong@walle-web.io
+"""
+
+from flask import request
+from walle.api.api import SecurityResource
+from walle.form.server import ServerForm
+from walle.model.deploy import ServerModel
+
+
+class ServerAPI(SecurityResource):
+    def get(self, id=None):
+        """
+        fetch environment list or one item
+        /environment/<int:env_id>
+
+        :return:
+        """
+        super(ServerAPI, self).get()
+
+        return self.item(id) if id else self.list()
+
+    def list(self):
+        """
+        fetch environment list
+
+        :return:
+        """
+        page = int(request.args.get('page', 0))
+        page = page - 1 if page else 0
+        size = float(request.args.get('size', 10))
+        kw = request.values.get('kw', '')
+
+        server_model = ServerModel()
+        server_list, count = server_model.list(page=page, size=size, kw=kw)
+        return self.list_json(list=server_list, count=count)
+
+    def item(self, id):
+        """
+        获取某个用户组
+
+        :param id:
+        :return:
+        """
+
+        server_model = ServerModel(id=id)
+        server_info = server_model.item()
+        if not server_info:
+            return self.render_json(code=-1)
+        return self.render_json(data=server_info)
+
+    def post(self):
+        """
+        create a environment
+        /environment/
+
+        :return:
+        """
+        super(ServerAPI, self).post()
+
+        form = ServerForm(request.form, csrf_enabled=False)
+        if form.validate_on_submit():
+            server_new = ServerModel()
+            id = server_new.add(name=form.name.data, host=form.host.data)
+            if not id:
+                return self.render_json(code=-1)
+
+            return self.render_json(data=server_new.item(id))
+        else:
+            return self.render_json(code=-1, message=form.errors)
+
+    def put(self, id):
+        """
+        update environment
+        /environment/<int:id>
+
+        :return:
+        """
+        super(ServerAPI, self).put()
+
+
+        form = ServerForm(request.form, csrf_enabled=False)
+        form.set_id(id)
+        if form.validate_on_submit():
+            server = ServerModel(id=id)
+            ret = server.update(name=form.name.data, host=form.host.data)
+            return self.render_json(data=server.item())
+        else:
+            return self.render_json(code=-1, message=form.errors)
+
+    def delete(self, id):
+        """
+        remove an environment
+        /environment/<int:id>
+
+        :return:
+        """
+        super(ServerAPI, self).delete()
+
+        server_model = ServerModel(id=id)
+        server_model.remove(id)
+
+        return self.render_json(message='')

+ 136 - 0
walle/api/space.py

@@ -0,0 +1,136 @@
+# -*- coding: utf-8 -*-
+"""
+
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2017-03-25 11:15:01
+    :author: wushuiyong@walle-web.io
+"""
+
+from flask import request, current_app, session
+from walle.api.api import SecurityResource
+from walle.form.space import SpaceForm
+from walle.model.user import SpaceModel, MemberModel, UserModel
+import json
+from walle.service.rbac.role import *
+from walle.service.extensions import permission
+
+class SpaceAPI(SecurityResource):
+
+    def get(self, space_id=None):
+        """
+        fetch space list or one item
+        /space/<int:space_id>
+
+        :return:
+        """
+        super(SpaceAPI, self).get()
+
+        return self.item(space_id) if space_id else self.list()
+
+    def list(self):
+        """
+        fetch space list
+
+        :return:
+        """
+        page = int(request.args.get('page', 0))
+        page = page - 1 if page else 0
+        size = float(request.args.get('size', 10))
+        kw = request.values.get('kw', '')
+
+        space_model = SpaceModel()
+        space_list, count = space_model.list(page=page, size=size, kw=kw)
+        return self.list_json(list=space_list, count=count, enable_create=permission.enable_role(OWNER))
+
+    def item(self, space_id):
+        """
+        获取某个用户组
+
+        :param id:
+        :return:
+        """
+
+        space_model = SpaceModel(id=space_id)
+        space_info = space_model.item()
+        if not space_info:
+            return self.render_json(code=-1)
+        return self.render_json(data=space_info)
+
+    def post(self):
+        """
+        create a space
+        /environment/
+
+        :return:
+        """
+        super(SpaceAPI, self).post()
+
+        form = SpaceForm(request.form, csrf_enabled=False)
+        # return self.render_json(code=-1, data = form.form2dict())
+        if form.validate_on_submit():
+            # create space
+            space_new = SpaceModel()
+            data = form.form2dict()
+            id = space_new.add(data)
+            if not id:
+                return self.render_json(code=-1)
+
+            current_app.logger.info(request.json)
+            # create group
+            data['role'] = OWNER
+            members = [data]
+            MemberModel(group_id=id).update_group(members=members)
+            return self.render_json(data=space_new.item())
+        else:
+            return self.render_json(code=-1, message=form.errors)
+
+    def put(self, space_id, action=None):
+        """
+        update environment
+        /environment/<int:id>
+
+        :return:
+        """
+        super(SpaceAPI, self).put()
+
+        if action and action == 'switch':
+            return self.switch(space_id)
+
+        form = SpaceForm(request.form, csrf_enabled=False)
+        form.set_id(space_id)
+        if form.validate_on_submit():
+            space = SpaceModel().get_by_id(space_id)
+            data = form.form2dict()
+            # a new type to update a model
+            ret = space.update(data)
+            # create group
+            if request.form.has_key('members'):
+                MemberModel(group_id=space_id).update_group(members=json.loads(request.form['members']))
+            return self.render_json(data=space.item())
+        else:
+            return self.render_json(code=-1, message=form.errors)
+
+    def delete(self, space_id):
+        """
+        remove an environment
+        /environment/<int:id>
+
+        :return:
+        """
+        super(SpaceAPI, self).delete()
+
+        space_model = SpaceModel(id=space_id)
+        space_model.remove(space_id)
+
+        return self.render_json(message='')
+
+    def switch(self, space_id):
+        session['space_id'] = space_id
+
+        # TODO
+        current_user.last_space = space_id
+        current_user.save()
+        UserModel.fresh_session()
+        return self.render_json()

+ 138 - 0
walle/api/task.py

@@ -0,0 +1,138 @@
+# -*- coding: utf-8 -*-
+"""
+
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2017-03-25 11:15:01
+    :author: wushuiyong@walle-web.io
+"""
+
+from flask import request, current_app, abort
+from walle.api.api import SecurityResource
+from walle.form.task import TaskForm
+from walle.model.deploy import TaskModel
+
+
+class TaskAPI(SecurityResource):
+    actions = ['audit', 'reject']
+
+    def get(self, task_id=None):
+        """
+        fetch project list or one item
+        /project/<int:project_id>
+        :return:
+        """
+        super(TaskAPI, self).get()
+
+        return self.item(task_id) if task_id else self.list()
+
+    def list(self):
+        """
+        fetch project list
+        :return:
+        """
+        page = int(request.args.get('page', 0))
+        page = page - 1 if page else 0
+        size = float(request.args.get('size', 10))
+        kw = request.values.get('kw', '')
+
+        task_model = TaskModel()
+        task_list, count = task_model.list(page=page, size=size, kw=kw)
+        return self.list_json(list=task_list, count=count)
+
+    def item(self, task_id):
+        """
+        获取某个用户组
+        :param id:
+        :return:
+        """
+
+        task_model = TaskModel(id=task_id)
+        task_info = task_model.item()
+        if not task_info:
+            return self.render_json(code=-1)
+        return self.render_json(data=task_info)
+
+    def post(self):
+        """
+        create a task
+        /task/
+        :return:
+        """
+        super(TaskAPI, self).post()
+
+        form = TaskForm(request.form, csrf_enabled=False)
+        # return self.render_json(code=-1, data = form.form2dict())
+        if form.validate_on_submit():
+            task_new = TaskModel()
+            data = form.form2dict()
+            id = task_new.add(data)
+            if not id:
+                return self.render_json(code=-1)
+
+            return self.render_json(data=task_new.item())
+        else:
+            return self.render_json(code=-1, message=form.errors)
+
+    def put(self, task_id, action=None):
+        """
+        update task
+        /task/<int:id>
+        :return:
+        """
+        super(TaskAPI, self).put()
+
+        if action:
+            if action in self.actions:
+                self_action = getattr(self, action.lower(), None)
+                return self_action(task_id=task_id)
+            else:
+                abort(404)
+        else:
+            return self.update(task_id=task_id)
+
+    def update(self, task_id):
+        form = TaskForm(request.form, csrf_enabled=False)
+        form.set_id(task_id)
+        if form.validate_on_submit():
+            task = TaskModel().get_by_id(task_id)
+            data = form.form2dict()
+            # a new type to update a model
+            ret = task.update(data)
+            return self.render_json(data=task.item())
+        else:
+            return self.render_json(code=-1, message=form.errors)
+
+    def delete(self, task_id):
+        """
+        remove an task
+        /task/<int:id>
+        :return:
+        """
+        super(TaskAPI, self).delete()
+
+        task_model = TaskModel(id=task_id)
+        task_model.remove(task_id)
+
+        return self.render_json(message='')
+
+    def audit(self, task_id):
+        """
+        审核任务
+        :param task_id:
+        :return:
+        """
+        task = TaskModel().get_by_id(task_id)
+        ret = task.update({'status': TaskModel.status_pass})
+        return self.render_json(data=task.item(task_id))
+
+    def reject(self, task_id):
+        """
+        审核任务
+        :param task_id:
+        :return:
+        """
+        task = TaskModel().get_by_id(task_id)
+        ret = task.update({'status': TaskModel.status_reject})
+        return self.render_json(data=task.item(task_id))

+ 182 - 0
walle/api/user.py

@@ -0,0 +1,182 @@
+# -*- coding: utf-8 -*-
+"""
+
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2017-03-25 11:15:01
+    :author: wushuiyong@walle-web.io
+"""
+import os
+from flask import request, current_app, abort
+from flask_login import current_user
+from walle.api.api import SecurityResource
+from walle.form.user import UserUpdateForm, RegistrationForm
+from walle.model.database import db
+from walle.model.user import MemberModel
+from walle.model.user import UserModel
+from werkzeug.security import generate_password_hash
+from walle.service.rbac.role import *
+from walle.service.extensions import permission
+
+
+class UserAPI(SecurityResource):
+    actions = ['avatar', 'block', 'active']
+
+    def get(self, user_id=None, method=None):
+        """
+        fetch user list or one user
+        /user/<int:user_id>
+
+        :return:
+        """
+        super(UserAPI, self).get()
+
+        return self.item(user_id) if user_id else self.list()
+
+    def list(self):
+        """
+        fetch user list or one user
+
+        :return:
+        """
+        page = int(request.args.get('page', 0))
+        page = page - 1 if page else 0
+        size = float(request.args.get('size', 10))
+        kw = request.values.get('kw', '')
+
+        uids = []
+        if current_user.role <> SUPER:
+            members = MemberModel(group_id=current_user.last_space).members()
+            uids = members['user_ids']
+
+        user_model = UserModel()
+        user_list, count = user_model.list(uids=uids, page=page, size=size, kw=kw)
+        filters = {
+            'username': ['线上', '线下'],
+            'status': ['正常', '禁用']
+        }
+        return self.list_json(list=user_list, count=count, table=self.table(filters), enable_create=permission.enable_role(MASTER))
+
+    def item(self, user_id):
+        """
+        获取某个用户
+
+        :param user_id:
+        :return:
+        """
+
+        user_info = UserModel(id=user_id).item()
+        if not user_info:
+            return self.render_json(code=-1)
+        return self.render_json(data=user_info)
+
+    def post(self, user_id=None, action=None):
+        """
+        create user
+        /user/
+
+        :return:
+        """
+        super(UserAPI, self).post()
+
+        if action and action == 'avatar':
+            return self.avatar(user_id)
+
+        form = RegistrationForm(request.form, csrf_enabled=False)
+        if form.validate_on_submit():
+            user = UserModel().add(form.form2dict())
+            return self.render_json(data=user.item(user_id=user.id))
+        return self.render_json(code=-1, message=form.errors)
+
+    def put(self, user_id, action=None):
+        """
+        edit user
+        /user/<int:user_id>
+
+        :return:
+        """
+        super(UserAPI, self).put()
+
+        if action:
+            if action in self.actions:
+                self_action = getattr(self, action.lower(), None)
+                return self_action(user_id=user_id)
+            else:
+                abort(404)
+
+        form = UserUpdateForm(request.form, csrf_enabled=False)
+        if form.validate_on_submit():
+            user = UserModel(id=user_id)
+            user.update_name_pwd(username=form.username.data, password=form.password.data)
+            return self.render_json(data=user.item())
+
+        return self.render_json(code=-1, message=form.errors)
+
+    def delete(self, user_id):
+        """
+        remove a user with his group relation
+        /user/<int:user_id>
+
+        :param user_id:
+        :return:
+        """
+        super(UserAPI, self).delete()
+
+        UserModel(id=user_id).remove()
+        MemberModel().remove(user_id=user_id)
+        return self.render_json(message='')
+
+    def table(self, filter={}):
+        table = {
+            'username': {
+                'sort': 0
+            },
+            'email': {
+                'sort': 0
+            },
+            'status': {
+                'sort': 0
+            },
+            'role_name': {
+                'sort': 0
+            },
+        }
+        ret = []
+        for (key, value) in table.items():
+            value['key'] = key
+            if key in filter:
+                value['value'] = filter[key]
+            else:
+                value['value'] = []
+            ret.append(value)
+        return ret
+
+    def avatar(self, user_id):
+        # TODO uid
+        # fname = current_user.id + '.jpg'
+        random = generate_password_hash(str(user_id))
+        fname = random[-10:] + '.jpg'
+        current_app.logger.info(fname)
+
+        f = request.files['avatar']
+        # todo rename to uid relation
+        # fname = secure_filename(f.filename)
+        # TODO try
+        ret = f.save(os.path.join(current_app.config['UPLOAD_AVATAR'], fname))
+        user = UserModel.query.get(user_id)
+        user.avatar = fname
+        user.save()
+        return self.render_json(data={
+            'avatar': UserModel.avatar_url(user.avatar),
+        })
+
+    def block(self, user_id):
+        user = UserModel(id=user_id)
+        user.block_active(UserModel.status_blocked)
+        return self.render_json(data=user.item())
+
+    def active(self, user_id):
+        user = UserModel(id=user_id)
+        user.block_active(UserModel.status_active)
+        return self.render_json(data=user.item())

+ 241 - 0
walle/app.py

@@ -0,0 +1,241 @@
+# -*- coding: utf-8 -*-
+"""The app module, containing the app factory function."""
+import logging
+import sys, os
+
+from flask import Flask, render_template, current_app, session,request, abort, Response
+from flask_restful import Api
+from tornado.ioloop import IOLoop
+from tornado.web import Application, FallbackHandler
+from tornado.wsgi import WSGIContainer
+from walle import commands
+from walle.api.api import ApiResource
+from walle.api import access as AccessAPI
+from walle.api import api as BaseAPI
+from walle.api import deploy as DeployAPI
+from walle.api import environment as EnvironmentAPI
+from walle.api import group as GroupAPI
+from walle.api import passport as PassportAPI
+from walle.api import project as ProjectAPI
+from walle.api import general as GeneralAPI
+from walle.api import role as RoleAPI
+from walle.api import server as ServerAPI
+from walle.api import task as TaskAPI
+from walle.api import user as UserAPI
+from walle.api import space as SpaceAPI
+from walle.api import repo as RepoApi
+from walle.config.settings_dev import DevConfig
+from walle.config.settings_test import TestConfig
+from walle.config.settings_prod import ProdConfig
+from walle.model.user import UserModel, MemberModel
+from walle.service.extensions import bcrypt, csrf_protect, db, migrate
+from walle.service.extensions import login_manager, mail, permission
+from walle.service.error import WalleError
+from walle.service.websocket import WSHandler
+
+from walle.service.code import Code
+from flask_login import current_user
+
+
+# TODO 添加到这,则对单测有影响
+# app = Flask(__name__.split('.')[0])
+
+def create_app(config_object=ProdConfig):
+    """An application factory, as explained here: http://flask.pocoo.org/docs/patterns/appfactories/.
+
+    :param config_object: The configuration object to use.
+    """
+    app = Flask(__name__.split('.')[0])
+    app.config.from_object(config_object)
+    register_extensions(app)
+    register_blueprints(app)
+    register_errorhandlers(app)
+    register_shellcontext(app)
+    register_commands(app)
+    register_logging(app)
+
+    @app.before_request
+    def before_request():
+        # current_app.logger.info(request)
+        # current_app.logger.info(app.request_class.url_rule)
+        # TODO
+        app.logger.info('============ @app.before_request ============')
+
+    @app.teardown_request
+    def shutdown_session(exception=None):
+        # TODO
+        from walle.model.database import db
+        db.session.remove()
+        current_app.logger.info('============ @app.teardown_request ============')
+
+    @app.route('/api/websocket')
+    def index():
+
+        return render_template('websocket.html')
+
+    # 测试环境跑单测失败
+    if not app.config['TESTING']:
+        register_websocket(app)
+
+    register_websocket(app)
+
+    reload(sys)
+    sys.setdefaultencoding('utf-8')
+
+    return app
+
+
+def register_extensions(app):
+    """Register Flask extensions."""
+    bcrypt.init_app(app)
+    db.init_app(app)
+    csrf_protect.init_app(app)
+    login_manager.session_protection = 'strong'
+
+    @login_manager.user_loader
+    def load_user(user_id):
+        current_app.logger.info(user_id)
+        app.logger.info('============ @app.user_loader ============')
+
+        return UserModel.query.get(user_id)
+
+
+    @login_manager.unauthorized_handler
+    def unauthorized():
+        # TODO log
+        current_app.logger.info('============ @login_manager.unauthorized_handler ============')
+        # return Response(ApiResource.render_json(code=Code.space_error))
+        return BaseAPI.ApiResource.json(code=Code.unlogin)
+
+    login_manager.init_app(app)
+
+    migrate.init_app(app, db)
+    mail.init_app(app)
+    permission.init_app(app)
+
+    return app
+
+
+def register_blueprints(app):
+    """Register Flask blueprints."""
+    api = Api(app)
+    api.add_resource(BaseAPI.Base, '/', endpoint='root')
+    api.add_resource(GeneralAPI.GeneralAPI, '/api/general/<string:action>', endpoint='general')
+    api.add_resource(SpaceAPI.SpaceAPI, '/api/space/', '/api/space/<int:space_id>', '/api/space/<int:space_id>/<string:action>', endpoint='space')
+    api.add_resource(DeployAPI.DeployAPI, '/api/deploy/', '/api/deploy/<int:task_id>', endpoint='deploy')
+    api.add_resource(AccessAPI.AccessAPI, '/api/access/', '/api/access/<int:access_id>', endpoint='access')
+    api.add_resource(RoleAPI.RoleAPI, '/api/role/', endpoint='role')
+    api.add_resource(GroupAPI.GroupAPI, '/api/group/', '/api/group/<int:group_id>', endpoint='group')
+    api.add_resource(PassportAPI.PassportAPI, '/api/passport/', '/api/passport/<string:action>', endpoint='passport')
+    api.add_resource(UserAPI.UserAPI, '/api/user/', '/api/user/<int:user_id>/<string:action>', '/api/user/<string:action>', '/api/user/<int:user_id>', endpoint='user')
+    api.add_resource(ServerAPI.ServerAPI, '/api/server/', '/api/server/<int:id>', endpoint='server')
+    api.add_resource(ProjectAPI.ProjectAPI, '/api/project/', '/api/project/<int:project_id>', '/api/project/<int:project_id>/<string:action>', endpoint='project')
+    api.add_resource(RepoApi.RepoAPI, '/api/repo/<string:action>/', endpoint='repo')
+    api.add_resource(TaskAPI.TaskAPI, '/api/task/', '/api/task/<int:task_id>', '/api/task/<int:task_id>/<string:action>', endpoint='task')
+    api.add_resource(EnvironmentAPI.EnvironmentAPI, '/api/environment/', '/api/environment/<int:env_id>',
+                     endpoint='environment')
+
+    return None
+
+
+def register_errorhandlers(app):
+    """Register error handlers."""
+    @app.errorhandler(WalleError)
+    def render_error(error):
+        app.logger.info('============ register_errorhandlers ============')
+        # response 的 json 内容为自定义错误代码和错误信息
+        return error.render_error()
+
+    def render_errors():
+
+        """Render error template."""
+        app.logger.info('============ render_errors ============')
+        # If a HTTPException, pull the `code` attribute; default to 500
+        return ApiResource.render_json(code=Code.space_error)
+    #
+    #     error_code = getattr(error, 'code', 500)
+    #     return render_template('{0}.html'.format(error_code)), error_code
+
+
+def register_shellcontext(app):
+    """Register shell context objects."""
+
+    def shell_context():
+        """Shell context objects."""
+        return {
+            'db': db,
+            'User': UserModel,
+        }
+
+    app.shell_context_processor(shell_context)
+
+
+def register_commands(app):
+    """Register Click commands."""
+    app.cli.add_command(commands.test)
+    app.cli.add_command(commands.lint)
+    app.cli.add_command(commands.clean)
+    app.cli.add_command(commands.urls)
+
+
+def register_logging(app):
+    # TODO https://blog.csdn.net/zwxiaoliu/article/details/80890136
+    # email errors to the administrators
+    import logging
+    from logging.handlers import RotatingFileHandler
+    # Formatter
+    formatter = logging.Formatter(
+            '%(asctime)s %(levelname)s %(pathname)s %(lineno)s %(module)s.%(funcName)s %(message)s')
+
+    # log dir
+    if not os.path.exists(app.config['LOG_PATH']):
+        os.makedirs(app.config['LOG_PATH'])
+
+    # FileHandler Info
+    file_handler_info = RotatingFileHandler(filename=app.config['LOG_PATH_INFO'])
+    file_handler_info.setFormatter(formatter)
+    file_handler_info.setLevel(logging.INFO)
+    info_filter = InfoFilter()
+    file_handler_info.addFilter(info_filter)
+    app.logger.addHandler(file_handler_info)
+
+    # FileHandler Error
+    file_handler_error = RotatingFileHandler(filename=app.config['LOG_PATH_ERROR'])
+    file_handler_error.setFormatter(formatter)
+    file_handler_error.setLevel(logging.ERROR)
+    app.logger.addHandler(file_handler_error)
+
+
+def register_websocket(app):
+    settings = {'debug': True}
+
+    # TODO fix websocket app_context
+    ctx = app.app_context()
+    ctx.push()
+
+    wsgi_app = WSGIContainer(app)
+
+    application = Application([
+        (r'/websocket/console', WSHandler),
+        (r'.*', FallbackHandler, dict(fallback=wsgi_app))
+    ], **settings)
+
+    application.listen(5000)
+    IOLoop.instance().start()
+    pass
+
+
+class InfoFilter(logging.Filter):
+    def filter(self, record):
+        """only use INFO
+        筛选, 只需要 INFO 级别的log
+        :param record:
+        :return:
+        """
+        if logging.INFO <= record.levelno < logging.ERROR:
+            # 已经是INFO级别了
+            # 然后利用父类, 返回 1
+            return 1
+        else:
+            return 0
+

+ 126 - 0
walle/commands.py

@@ -0,0 +1,126 @@
+# -*- coding: utf-8 -*-
+"""Click commands."""
+import os
+from glob import glob
+from subprocess import call
+
+import click
+from flask import current_app
+from flask.cli import with_appcontext
+from werkzeug.exceptions import MethodNotAllowed, NotFound
+
+HERE = os.path.abspath(os.path.dirname(__file__))
+PROJECT_ROOT = os.path.join(HERE, os.pardir)
+TEST_PATH = os.path.join(PROJECT_ROOT, 'tests')
+
+
+@click.command()
+def test():
+    """Run the tests."""
+    import pytest
+    rv = pytest.main([TEST_PATH, '--verbose'])
+    exit(rv)
+
+
+@click.command()
+@click.option('-f', '--fix-imports', default=False, is_flag=True,
+              help='Fix imports using isort, before linting')
+def lint(fix_imports):
+    """Lint and check code style with flake8 and isort."""
+    skip = ['requirements']
+    root_files = glob('*.py')
+    root_directories = [
+        name for name in next(os.walk('.'))[1] if not name.startswith('.')]
+    files_and_directories = [
+        arg for arg in root_files + root_directories if arg not in skip]
+
+    def execute_tool(description, *args):
+        """Execute a checking tool with its arguments."""
+        command_line = list(args) + files_and_directories
+        click.echo('{}: {}'.format(description, ' '.join(command_line)))
+        rv = call(command_line)
+        if rv != 0:
+            exit(rv)
+
+    if fix_imports:
+        execute_tool('Fixing import order', 'isort', '-rc')
+    execute_tool('Checking code style', 'flake8')
+
+
+@click.command()
+def clean():
+    """Remove *.pyc and *.pyo files recursively starting at current directory.
+
+    Borrowed from Flask-Script, converted to use Click.
+    """
+    for dirpath, dirnames, filenames in os.walk('.'):
+        for filename in filenames:
+            if filename.endswith('.pyc') or filename.endswith('.pyo'):
+                full_pathname = os.path.join(dirpath, filename)
+                click.echo('Removing {}'.format(full_pathname))
+                os.remove(full_pathname)
+
+
+@click.command()
+@click.option('--url', default=None,
+              help='Url to test (ex. /static/image.png)')
+@click.option('--order', default='rule',
+              help='Property on Rule to order by (default: rule)')
+@with_appcontext
+def urls(url, order):
+    """Display all of the url matching routes for the project.
+
+    Borrowed from Flask-Script, converted to use Click.
+    """
+    rows = []
+    column_length = 0
+    column_headers = ('Rule', 'Endpoint', 'Arguments')
+
+    if url:
+        try:
+            rule, arguments = (
+                current_app.url_map
+                    .bind('localhost')
+                    .match(url, return_rule=True))
+            rows.append((rule.rule, rule.endpoint, arguments))
+            column_length = 3
+        except (NotFound, MethodNotAllowed) as e:
+            rows.append(('<{}>'.format(e), None, None))
+            column_length = 1
+    else:
+        rules = sorted(
+                current_app.url_map.iter_rules(),
+                key=lambda rule: getattr(rule, order))
+        for rule in rules:
+            rows.append((rule.rule, rule.endpoint, None))
+        column_length = 2
+
+    str_template = ''
+    table_width = 0
+
+    if column_length >= 1:
+        max_rule_length = max(len(r[0]) for r in rows)
+        max_rule_length = max_rule_length if max_rule_length > 4 else 4
+        str_template += '{:' + str(max_rule_length) + '}'
+        table_width += max_rule_length
+
+    if column_length >= 2:
+        max_endpoint_length = max(len(str(r[1])) for r in rows)
+        # max_endpoint_length = max(rows, key=len)
+        max_endpoint_length = (
+            max_endpoint_length if max_endpoint_length > 8 else 8)
+        str_template += '  {:' + str(max_endpoint_length) + '}'
+        table_width += 2 + max_endpoint_length
+
+    if column_length >= 3:
+        max_arguments_length = max(len(str(r[2])) for r in rows)
+        max_arguments_length = (
+            max_arguments_length if max_arguments_length > 9 else 9)
+        str_template += '  {:' + str(max_arguments_length) + '}'
+        table_width += 2 + max_arguments_length
+
+    click.echo(str_template.format(*column_headers[:column_length]))
+    click.echo('-' * table_width)
+
+    for row in rows:
+        click.echo(str_template.format(*row[:column_length]))

+ 8 - 0
walle/config/__init__.py

@@ -0,0 +1,8 @@
+# -*- coding: utf-8 -*-
+"""
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2017-06-14 15:28:18
+    :author: wushuiyong@walle-web.io
+"""

+ 27 - 0
walle/config/settings.py

@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+"""Application configuration."""
+import os
+
+
+class Config(object):
+    """Base configuration."""
+
+    SECRET_KEY = os.environ.get('WALLE_SECRET', 'secret-key')  # TODO: Change me
+    APP_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))  # This directory
+    PROJECT_ROOT = os.path.abspath(os.path.join(APP_DIR, os.pardir))
+    BCRYPT_LOG_ROUNDS = 13
+    ASSETS_DEBUG = False
+    DEBUG_TB_ENABLED = False  # Disable Debug toolbar
+    DEBUG_TB_INTERCEPT_REDIRECTS = False
+    CACHE_TYPE = 'simple'  # Can be "memcached", "redis", etc.
+    SQLALCHEMY_TRACK_MODIFICATIONS = False
+    SQLALCHEMY_COMMIT_ON_TEARDOWN = True
+
+    AVATAR_PATH = 'avatar/'
+
+    LOGIN_DISABLED = False
+
+
+    LOCAL_SERVER_HOST = '127.0.0.1'
+    LOCAL_SERVER_USER = 'wushuiyong'
+    LOCAL_SERVER_PORT = 22

+ 51 - 0
walle/config/settings_dev.py

@@ -0,0 +1,51 @@
+# -*- coding: utf-8 -*-
+"""Application configuration."""
+import os
+from walle.config.settings import Config
+from datetime import timedelta
+
+class DevConfig(Config):
+    """Development configuration."""
+
+    ENV = 'dev'
+    DEBUG = True
+    DB_NAME = 'walle_python'
+    # Put the db file in project root
+    WTF_CSRF_ENABLED = False
+    DB_PATH = os.path.join(Config.PROJECT_ROOT, DB_NAME)
+    # SQLALCHEMY_DATABASE_URI = 'sqlite:///{0}'.format(DB_PATH)
+    SQLALCHEMY_DATABASE_URI = 'mysql://root:whoiam@localhost/walle_python'
+    DEBUG_TB_ENABLED = True
+    ASSETS_DEBUG = True  # Don't bundle/minify static assets
+    CACHE_TYPE = 'simple'  # Can be "memcached", "redis", etc.
+    PERMANENT_SESSION_LIFETIME = timedelta(days=1) #设置session的保存时间。
+
+    # 前端项目部署路径
+    FE_PATH = '/Users/wushuiyong/workspace/meolu/walle-fe/'
+    AVATAR_PATH = 'avatar/'
+    UPLOAD_AVATAR = FE_PATH + 'dist/' + AVATAR_PATH
+
+    #email config
+    MAIL_SERVER = 'smtp.exmail.qq.com'
+    MAIL_PORT = 465
+    MAIL_USE_SSL = True
+    MAIL_USE_TLS = False
+    MAIL_DEFAULT_SENDER = 'service@walle-web.io'
+    MAIL_USERNAME = 'service@walle-web.io'
+    MAIL_PASSWORD = 'Ki9y&3U82'
+
+    LOG_PATH = os.path.join(Config.PROJECT_ROOT, 'logs')
+    LOG_PATH_ERROR = os.path.join(LOG_PATH, 'error.log')
+    LOG_PATH_INFO = os.path.join(LOG_PATH, 'info.log')
+    LOG_PATH_DEBUG = os.path.join(LOG_PATH, 'debug.log')
+    LOG_FILE_MAX_BYTES = 100 * 1024 * 1024
+    # 轮转数量是 10 个
+    LOG_FILE_BACKUP_COUNT = 10
+    LOG_FORMAT = "%(asctime)s %(thread)d %(message)s"
+
+
+    LOCAL_SERVER_HOST = '127.0.0.1'
+    LOCAL_SERVER_USER = 'wushuiyong'
+    LOCAL_SERVER_PORT = 22
+
+    SQLALCHEMY_ECHO = True

+ 19 - 0
walle/config/settings_prod.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+"""Application configuration."""
+import os
+from walle.config.settings import Config
+
+
+class ProdConfig(Config):
+    """Production configuration."""
+
+    ENV = 'prod'
+    DEBUG = False
+    SQLALCHEMY_DATABASE_URI = 'postgresql://localhost/example'  # TODO: Change me
+    DEBUG_TB_ENABLED = False  # Disable Debug toolbar
+
+
+    # 前端项目部署路径
+    FE_PATH = '/Users/wushuiyong/workspace/meolu/walle-fe/'
+    UPLOAD_AVATER = FE_PATH + 'dist/avater/'
+

+ 50 - 0
walle/config/settings_test.py

@@ -0,0 +1,50 @@
+# -*- coding: utf-8 -*-
+"""Application configuration."""
+import os
+from walle.config.settings import Config
+
+class TestConfig(Config):
+    """Test configuration."""
+
+    TESTING = True
+    ENV = 'test'
+    DEBUG = True
+    DB_NAME = 'walle_python'
+    # Put the db file in project root
+    DB_PATH = os.path.join(Config.PROJECT_ROOT, DB_NAME)
+    # SQLALCHEMY_DATABASE_URI = 'sqlite:///{0}'.format(DB_PATH)
+    SQLALCHEMY_DATABASE_URI = 'sqlite://'
+    BCRYPT_LOG_ROUNDS = 4  # For faster tests; needs at least 4 to avoid "ValueError: Invalid rounds"
+    WTF_CSRF_ENABLED = False  # Allows form testing
+    DEBUG_TB_ENABLED = True
+    ASSETS_DEBUG = True  # Don't bundle/minify static assets
+    CACHE_TYPE = 'simple'  # Can be "memcached", "redis", etc.
+
+    # 前端项目部署路径
+    FE_PATH = '/Users/wushuiyong/workspace/meolu/walle-fe/'
+    AVATAR_PATH = 'avatar/'
+    UPLOAD_AVATAR = FE_PATH + 'dist/' + AVATAR_PATH
+
+    #email config
+    MAIL_SERVER = 'smtp.exmail.qq.com'
+    MAIL_PORT = 465
+    MAIL_USE_SSL = True
+    MAIL_USE_TLS = False
+    MAIL_DEFAULT_SENDER = 'service@walle-web.io'
+    MAIL_USERNAME = 'service@walle-web.io'
+    MAIL_PASSWORD = 'Ki9y&3U82'
+
+    LOG_PATH = os.path.join(Config.PROJECT_ROOT, 'logs')
+    LOG_PATH_ERROR = os.path.join(LOG_PATH, 'error.log')
+    LOG_PATH_INFO = os.path.join(LOG_PATH, 'info.log')
+    LOG_PATH_DEBUG = os.path.join(LOG_PATH, 'debug.log')
+    LOG_FILE_MAX_BYTES = 100 * 1024 * 1024
+    # 轮转数量是 10 个
+    LOG_FILE_BACKUP_COUNT = 10
+    LOG_FORMAT = "%(asctime)s %(thread)d %(message)s"
+
+    LOCAL_SERVER_HOST = '127.0.0.1'
+    LOCAL_SERVER_USER = 'wushuiyong'
+    LOCAL_SERVER_PORT = 22
+
+    SQLALCHEMY_ECHO = True

+ 8 - 0
walle/form/__init__.py

@@ -0,0 +1,8 @@
+# -*- coding: utf-8 -*-
+"""
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2017-06-14 15:22:48
+    :author: wushuiyong@walle-web.io
+"""

+ 36 - 0
walle/form/environment.py

@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+"""
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2017-03-19 15:50:07
+    :author: wushuiyong@walle-web.io
+"""
+try:
+    from flask_wtf import FlaskForm  # Try Flask-WTF v0.13+
+except ImportError:
+    from flask_wtf import Form as FlaskForm  # Fallback to Flask-WTF v0.12 or older
+from flask_wtf import Form
+from wtforms import TextField
+from wtforms import validators, ValidationError
+
+from walle.model.deploy import EnvironmentModel
+
+
+class EnvironmentForm(Form):
+    env_name = TextField('env_name', [validators.Length(min=1, max=100)])
+    status = TextField('status', [validators.Length(min=0, max=10)])
+    env_id = None
+
+    def set_env_id(self, env_id):
+        self.env_id = env_id
+
+    def validate_env_name(self, field):
+        env = EnvironmentModel.query.filter_by(name=field.data).first()
+        # 新建时,环境名不可与
+        if env and env.id != self.env_id:
+            raise ValidationError('该环境已经配置过')
+
+    def validate_status(self, field):
+        if field.data and int(field.data) not in [1, 2]:
+            raise ValidationError('非法的状态')

+ 50 - 0
walle/form/group.py

@@ -0,0 +1,50 @@
+# -*- coding: utf-8 -*-
+"""
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2017-03-19 15:50:07
+    :author: wushuiyong@walle-web.io
+"""
+try:
+    from FlaskForm import FlaskForm  # Try Flask-WTF v0.13+
+except ImportError:
+    from flask_wtf import Form as FlaskForm  # Fallback to Flask-WTF v0.12 or older
+from flask_wtf import Form
+from wtforms import TextField
+from wtforms import validators, ValidationError
+
+from walle.model.user import UserModel
+from walle.model.tag import TagModel
+import json
+from flask import current_app
+
+
+class GroupForm(Form):
+    group_name = TextField('group_name', [validators.Length(min=1, max=100)])
+    uid_roles = TextField('uid_roles', [validators.Length(min=1)])
+    group_id = None
+
+    def set_group_id(self, group_id):
+        self.group_id = group_id
+
+    def validate_user_ids(self, field):
+        current_app.logger.info(field.data)
+        self.uid_roles = json.loads(field.data)
+
+
+        user_ids = [uid_role['user_id'] for uid_role in self.uid_roles]
+        roles = [uid_role['role'] for uid_role in self.uid_roles]
+        # TODO validator roles
+        # current_app.logger.info(user_ids)
+        if UserModel.query.filter(UserModel.id.in_(user_ids)).count() != len(user_ids):
+            raise ValidationError('存在未记录的用户添加到用户组')
+
+    def validate_group_name(self, field):
+        env = TagModel.query.filter_by(name=field.data).filter_by(label='user_group').first()
+        # 新建时,环境名不可与
+        if env and env.id != self.group_id:
+            raise ValidationError('该用户组已经配置过')
+
+    def validate_members(self, field):
+        pass

+ 93 - 0
walle/form/project.py

@@ -0,0 +1,93 @@
+# -*- coding: utf-8 -*-
+"""
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2017-03-19 15:50:07
+    :author: wushuiyong@walle-web.io
+"""
+try:
+    from flask_wtf import FlaskForm  # Try Flask-WTF v0.13+
+except ImportError:
+    from flask_wtf import Form as FlaskForm  # Fallback to Flask-WTF v0.12 or older
+from flask_wtf import Form
+from wtforms import TextField
+from wtforms import validators, ValidationError
+
+from walle.model.deploy import ProjectModel
+
+
+class ProjectForm(Form):
+    name = TextField('name', [validators.Length(min=1, max=100)])
+    environment_id = TextField('environment_id', [validators.Length(min=1, max=10)])
+    space_id = TextField('space_id', [validators.Length(min=1, max=10)])
+    status = TextField('status', [])
+    excludes = TextField('excludes', [])
+    master = TextField('master', [])
+    server_ids = TextField('server_ids', [validators.Length(min=1)])
+    keep_version_num = TextField('keep_version_num', [])
+
+    target_user = TextField('target_user', [validators.Length(min=1, max=50)])
+    target_port = TextField('target_port', [validators.Length(min=1, max=50)])
+    target_root = TextField('target_root', [validators.Length(min=1, max=200)])
+    target_releases = TextField('target_releases', [validators.Length(min=1, max=200)])
+
+    task_vars = TextField('task_vars', [])
+    prev_deploy = TextField('prev_deploy', [])
+    post_deploy = TextField('post_deploy', [])
+    prev_release = TextField('prev_release', [])
+    post_release = TextField('post_release', [])
+
+    repo_url = TextField('repo_url', [validators.Length(min=1, max=200)])
+    repo_username = TextField('repo_username', [])
+    repo_password = TextField('repo_password', [])
+    repo_mode = TextField('repo_mode', [validators.Length(min=1, max=50)])
+    notice_type = TextField('notice_type', [])
+    notice_hook = TextField('notice_hook', [])
+    enable_audit = TextField('enable_audit', [])
+
+    id = None
+
+    def set_id(self, id):
+        self.id = id
+
+    def validate_name(self, field):
+        server = ProjectModel.query.filter_by(name=field.data).first()
+        # 新建时,项目名不可与
+        if server and server.id != self.id:
+            raise ValidationError('该项目已重名')
+
+    def form2dict(self):
+        return {
+            'name': self.name.data if self.name.data else '',
+            # TODO g.uid
+            'user_id': 1,
+
+            'status': self.status.data if self.status.data else 0,
+            'master': self.master.data if self.master.data else '',
+            'environment_id': self.environment_id.data if self.environment_id.data else '',
+            'space_id': self.space_id.data if self.space_id.data else '',
+            'excludes': self.excludes.data if self.excludes.data else '',
+            'server_ids': self.server_ids.data if self.server_ids.data else '',
+            'keep_version_num': self.keep_version_num.data if self.keep_version_num.data else 5,
+
+            'target_user': self.target_user.data if self.target_user.data else '',
+            'target_port': self.target_port.data if self.target_port.data else '',
+            'target_root': self.target_root.data if self.target_root.data else '',
+            'target_releases': self.target_releases.data if self.target_releases.data else '',
+
+            'task_vars': self.task_vars.data if self.task_vars.data else '',
+            'prev_deploy': self.prev_deploy.data if self.prev_deploy.data else '',
+            'post_deploy': self.post_deploy.data if self.post_deploy.data else '',
+            'prev_release': self.prev_release.data if self.prev_release.data else '',
+            'post_release': self.post_release.data if self.post_release.data else '',
+
+            'repo_url': self.repo_url.data if self.repo_url.data else '',
+            'repo_username': self.repo_username.data if self.repo_username.data else '',
+            'repo_password': self.repo_password.data if self.repo_password.data else '',
+            'repo_mode': self.repo_mode.data if self.repo_mode.data else '',
+
+            'notice_type': self.notice_type.data if self.notice_type.data else '',
+            'notice_hook': self.notice_hook.data if self.notice_hook.data else '',
+            'enable_audit': self.enable_audit.data if self.enable_audit.data else 0,
+        }

+ 21 - 0
walle/form/role.py

@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+"""
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2017-03-19 15:50:07
+    :author: wushuiyong@walle-web.io
+"""
+try:
+    from flask_wtf import FlaskForm  # Try Flask-WTF v0.13+
+except ImportError:
+    from flask_wtf import Form as FlaskForm  # Fallback to Flask-WTF v0.12 or older
+from flask_wtf import Form
+
+from wtforms import TextField
+from wtforms import validators
+
+
+class RoleAdd(Form):
+    name = TextField('Email Address', [validators.Length(min=6, max=35), validators.InputRequired()])
+    # password = SelectField('Password', [validators.Length(min=6, max=35))

+ 32 - 0
walle/form/server.py

@@ -0,0 +1,32 @@
+# -*- coding: utf-8 -*-
+"""
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2017-03-19 15:50:07
+    :author: wushuiyong@walle-web.io
+"""
+try:
+    from flask_wtf import FlaskForm  # Try Flask-WTF v0.13+
+except ImportError:
+    from flask_wtf import Form as FlaskForm  # Fallback to Flask-WTF v0.12 or older
+from flask_wtf import Form
+from wtforms import TextField
+from wtforms import validators, ValidationError
+
+from walle.model.deploy import ServerModel
+
+
+class ServerForm(Form):
+    name = TextField('name', [validators.Length(min=1, max=100)])
+    host = TextField('host', [validators.Length(min=1, max=100)])
+    id = None
+
+    def set_id(self, id):
+        self.id = id
+
+    def validate_name(self, field):
+        server = ServerModel.query.filter_by(name=field.data).first()
+        # 新建时,环境名不可与
+        if server and server.id != self.id:
+            raise ValidationError('该Server已重名')

+ 41 - 0
walle/form/space.py

@@ -0,0 +1,41 @@
+# -*- coding: utf-8 -*-
+"""
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2017-03-19 15:50:07
+    :author: wushuiyong@walle-web.io
+"""
+try:
+    from flask_wtf import FlaskForm  # Try Flask-WTF v0.13+
+except ImportError:
+    from flask_wtf import Form as FlaskForm  # Fallback to Flask-WTF v0.12 or older
+from flask_wtf import Form
+from wtforms import TextField
+from wtforms import validators, ValidationError
+
+from walle.model.user import SpaceModel
+
+
+class SpaceForm(Form):
+    name = TextField('name', [validators.Length(min=1, max=100)])
+    user_id = TextField('user_id', [validators.Length(min=1, max=100)])
+    status = TextField('status', [])
+    id = None
+
+    def set_id(self, id):
+        self.id = id
+
+    def validate_name(self, field):
+        space = SpaceModel.query.filter_by(name=field.data).first()
+        # 新建时,环境名不可与
+        if space and space.id != self.id:
+            raise ValidationError('该Space已重名')
+
+    def form2dict(self):
+        return {
+            'name': self.name.data if self.name.data else '',
+            # TODO g.uid
+            'user_id': self.user_id.data if self.user_id.data else '',
+            'status': 1,
+        }

+ 21 - 0
walle/form/tag.py

@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+"""
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2017-03-19 15:50:07
+    :author: wushuiyong@walle-web.io
+"""
+try:
+    from flask_wtf import FlaskForm  # Try Flask-WTF v0.13+
+except ImportError:
+    from flask_wtf import Form as FlaskForm  # Fallback to Flask-WTF v0.12 or older
+from flask_wtf import Form
+
+from wtforms import TextField
+from wtforms import validators
+
+
+class TagCreateForm(Form):
+    name = TextField('name', [validators.Length(min=1, max=10)])
+    label = TextField('label', [validators.Length(min=1, max=30)])

+ 55 - 0
walle/form/task.py

@@ -0,0 +1,55 @@
+# -*- coding: utf-8 -*-
+"""
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2017-03-19 15:50:07
+    :author: wushuiyong@walle-web.io
+"""
+try:
+    from flask_wtf import FlaskForm  # Try Flask-WTF v0.13+
+except ImportError:
+    from flask_wtf import Form as FlaskForm  # Fallback to Flask-WTF v0.12 or older
+from flask_wtf import Form
+
+from wtforms import TextField, IntegerField
+from wtforms import validators
+
+
+class TaskForm(Form):
+    name = TextField('name', [validators.Length(min=1)])
+    project_id = IntegerField('project_id', [validators.NumberRange(min=1)])
+    servers = TextField('servers', [validators.Length(min=1)])
+    commit_id = TextField('commit_id', [])
+    status = IntegerField('status', [])
+    # TODO 应该增加一个tag/branch其一必填
+    tag = TextField('tag', [])
+    branch = TextField('branch', [])
+    file_transmission_mode = IntegerField('file_transmission_mode', [])
+    file_list = TextField('file_list', [])
+
+    id = None
+
+    def set_id(self, id):
+        self.id = id
+
+    def form2dict(self):
+        return {
+            'name': self.name.data if self.name.data else '',
+            # todo
+            'user_id': 1,
+            'project_id': self.project_id.data if self.project_id.data else '',
+            # todo default value
+            'action': 0,
+            'status': self.status.data if self.status.data else 0,
+            'link_id': '',
+            'ex_link_id': '',
+            'servers': self.servers.data if self.servers.data else '',
+            'commit_id': self.commit_id.data if self.commit_id.data else '',
+            'tag': self.tag.data if self.tag.data else '',
+            'branch': self.branch.data if self.branch.data else '',
+            'file_transmission_mode': self.file_transmission_mode.data if self.file_transmission_mode.data else 0,
+            'file_list': self.file_list.data if self.file_list.data else '',
+            'enable_rollback': 1,
+
+        }

+ 62 - 0
walle/form/user.py

@@ -0,0 +1,62 @@
+# -*- coding: utf-8 -*-
+"""
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2017-03-19 15:50:07
+    :author: wushuiyong@walle-web.io
+"""
+try:
+    from flask_wtf import FlaskForm  # Try Flask-WTF v0.13+
+except ImportError:
+    from flask_wtf import Form as FlaskForm  # Fallback to Flask-WTF v0.12 or older
+from flask_wtf import Form
+from wtforms import PasswordField, TextField
+from wtforms import validators, ValidationError
+from wtforms.validators import Regexp
+
+from walle.model.user import RoleModel
+from walle.model.user import UserModel
+from flask import current_app
+import re
+from werkzeug.security import generate_password_hash
+
+class UserForm(FlaskForm):
+    email = TextField('Email Address', [validators.email()])
+    password = PasswordField('Password', [validators.Length(min=6, max=35),
+                                          validators.Regexp(regex="^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[A-Za-z\d]{6,}",
+                              message='密码至少6个字符,至少1个大写字母,1个小写字母,1个数字')])
+
+    username = TextField('Username', [validators.Length(min=1, max=50)])
+    role = TextField('role', [])
+
+    def validate_email(self, field):
+            if UserModel.query.filter_by(email=field.data).first():
+                raise ValidationError('Email already register')
+
+    def form2dict(self):
+        return {
+            'username': self.username.data,
+            'password': generate_password_hash(self.password.data),
+            'email': self.email.data,
+            'role': self.role.data if self.role.data else '',
+        }
+
+
+class RegistrationForm(UserForm):
+
+    pass
+
+
+class UserUpdateForm(Form):
+    password = PasswordField('Password', [])
+    username = TextField('username', [validators.Length(min=1, max=50)])
+    def validate_password(self, field):
+        if field.data and not re.match("^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[A-Za-z\d]{6,}", field.data):
+            raise ValidationError('密码至少6个字符,至少1个大写字母,1个小写字母,1个数字')
+
+
+class LoginForm(Form):
+    email = TextField('Email Address', [validators.Length(min=6, max=35),
+                                        Regexp(r'^(.+)@(.+)\.(.+)', message='邮箱格式不正确')])
+    password = PasswordField('Password', [validators.Length(min=6, max=35)])

+ 8 - 0
walle/model/__init__.py

@@ -0,0 +1,8 @@
+# -*- coding: utf-8 -*-
+"""
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2017-06-14 15:32:09
+    :author: wushuiyong@walle-web.io
+"""

+ 307 - 0
walle/model/database.py

@@ -0,0 +1,307 @@
+# -*- coding: utf-8 -*-
+"""Database module, including the SQLAlchemy database object and DB-related utilities."""
+from pprint import pformat
+
+from sqlalchemy import desc, or_
+from sqlalchemy.sql.sqltypes import Date, DateTime
+from werkzeug import cached_property
+from flask import current_app
+from walle.service.extensions import db
+from walle.service.utils import basestring
+from walle.service.utils import datetime_str_to_obj, date_str_to_obj
+
+# Alias common SQLAlchemy names
+Column = db.Column
+relationship = db.relationship
+
+OPERATOR_FUNC_DICT = {
+    '=': (lambda cls, k, v: getattr(cls, k) == v),
+    '==': (lambda cls, k, v: getattr(cls, k) == v),
+    'eq': (lambda cls, k, v: getattr(cls, k) == v),
+    '!=': (lambda cls, k, v: getattr(cls, k) != v),
+    'ne': (lambda cls, k, v: getattr(cls, k) != v),
+    'neq': (lambda cls, k, v: getattr(cls, k) != v),
+    '>': (lambda cls, k, v: getattr(cls, k) > v),
+    'gt': (lambda cls, k, v: getattr(cls, k) > v),
+    '>=': (lambda cls, k, v: getattr(cls, k) >= v),
+    'gte': (lambda cls, k, v: getattr(cls, k) >= v),
+    '<': (lambda cls, k, v: getattr(cls, k) < v),
+    'lt': (lambda cls, k, v: getattr(cls, k) < v),
+    '<=': (lambda cls, k, v: getattr(cls, k) <= v),
+    'lte': (lambda cls, k, v: getattr(cls, k) <= v),
+    'or': (lambda cls, k, v: or_(getattr(cls, k) == value for value in v)),
+    'in': (lambda cls, k, v: getattr(cls, k).in_(v)),
+    'nin': (lambda cls, k, v: ~getattr(cls, k).in_(v)),
+    'like': (lambda cls, k, v: getattr(cls, k).like('%%%s%%' % (v))),
+    'nlike': (lambda cls, k, v: ~getattr(cls, k).like(v)),
+    '+': (lambda cls, k, v: getattr(cls, k) + v),
+    'incr': (lambda cls, k, v: getattr(cls, k) + v),
+    '-': (lambda cls, k, v: getattr(cls, k) - v),
+    'decr': (lambda cls, k, v: getattr(cls, k) - v),
+}
+
+
+def parse_operator(cls, filter_name_dict):
+    """ 用来返回sqlalchemy query对象filter使用的表达式
+    Args:
+        filter_name_dict (dict): 过滤条件dict
+        {
+            'last_name': {'eq': 'wang'},    # 如果是dic使用key作为操作符
+            'age': {'>': 12}
+        }
+    Returns:
+        binary_expression_list (lambda list)
+    """
+
+    def _change_type(cls, field, value):
+        """ 有些表字段比如DateTime类型比较的时候需要转换类型,
+        前端传过来的都是字符串,Date等类型没法直接相比较,需要转成Date类型
+        Args:
+            cls (class): Model class
+            field (str): Model class field
+            value (str): value need to compare
+        """
+        field_type = getattr(cls, field).type
+        if isinstance(field_type, Date):
+            return date_str_to_obj(value)
+        elif isinstance(field_type, DateTime):
+            return datetime_str_to_obj(value)
+        else:
+            return value
+
+    binary_expression_list = []
+    for field, op_dict in filter_name_dict.items():
+        for op, op_val in op_dict.items():
+            op_val = _change_type(cls, field, op_val)
+            if op in OPERATOR_FUNC_DICT:
+                binary_expression_list.append(
+                        OPERATOR_FUNC_DICT[op](cls, field, op_val)
+                )
+    return binary_expression_list
+
+
+class CRUDMixin(object):
+    """Mixin that adds convenience methods for
+    CRUD (create, read, update, delete) operations."""
+
+    @classmethod
+    def create(cls, **kwargs):
+        """Create a new record and save it the database."""
+        instance = cls(**kwargs)
+        return instance.save()
+
+    @classmethod
+    def create_from_dict(cls, d):
+        """Create a new record and save it the database."""
+        assert isinstance(d, dict)
+        instance = cls(**d)
+        return instance.save()
+
+    def update(self, commit=True, **kwargs):
+        """Update specific fields of a record."""
+        for attr, value in kwargs.items():
+            setattr(self, attr, value)
+        return commit and self.save() or self
+
+    def save(self, commit=True):
+        """Save the record."""
+        db.session.add(self)
+        if commit:
+            try:
+                db.session.commit()
+            except Exception as e:
+                current_app.logger.info(e)
+                db.session.rollback()
+        return self
+
+    def delete(self, commit=True):
+        """Remove the record from the database."""
+        db.session.delete(self)
+        if commit:
+            try:
+                db.session.commit()
+            except:
+                db.session.rollback()
+        return self
+
+    def to_dict(self, fields_list=None):
+        """
+        Args:
+            fields (str list): 指定返回的字段
+        """
+        column_list = fields_list or [
+            column.name for column in self.__table__.columns
+            ]
+        return {
+            column_name: getattr(self, column_name)
+            for column_name in column_list
+            }
+
+    @classmethod
+    def create_or_update(cls, query_dict, update_dict=None):
+        instance = db.session.query(cls).filter_by(**query_dict).first()
+        if instance:  # update
+            if update_dict is not None:
+                return instance.update(**update_dict)
+            else:
+                return instance
+        else:  # create new instance
+            query_dict.update(update_dict or {})
+            return cls.create(**query_dict)
+
+    @classmethod
+    def query_paginate(cls, page=1, limit=20, fields=None, order_by_list=[('id', 'desc')],
+                       filter_name_dict=None):
+        """ 通用的分页查询函数
+        Args:
+            page (int): 页数
+            limit (int): 每页个数
+            order_by_list (tuple list): 用来指定排序的字段,可以传多个
+                [ ('id', 1), ('name', -1) ],1表示正序,-1表示逆序
+                or
+                [ ('id', 'asc'), ('name', 'desc') ],1表示正序,-1表示逆序
+
+            filter_name_dict (dict): 过滤条件,使用字典表示,使用字段名作为key,value
+                是{'operator': to_compare_value}, e.g.:
+                {
+                    'last_name': {'eq': 'wang'},  # 如果是dic使用key作为操作符
+                    'age': {'>': 12}
+                }
+
+        Returns:
+            if fields is not None:
+                (keytuple_list, total_cnt) (tuple)
+            else:
+                (instance_list, total_cnt) (tuple)
+
+        前段查询参数规范:
+        request.args 示例:
+        ImmutableMultiDict([('limit', '10'), ('page', '1'), ('filter', '[{"field":"name","op":"eq","q":"g"},{
+        "field":"id","op":"gt","q":"5"
+        }]')])
+
+        page: 页码
+        limit: 每页限制
+        order: 顺序,取值"asc" "desc". """'name', 'asc', 'model', 'desc'"""
+        fields: 需要返回的字段
+        filter: 过滤条件:
+        {
+            field: 需要过滤的字段
+            op: 过滤操作符,支持"eq","neq","gt","gte","lt","lte","in","nin","like"
+            q: 过滤值
+        }
+        """
+        fields = (
+            [getattr(cls, column) for column in fields] if fields is not None
+            else None
+        )
+        if fields:
+            query = db.session.query(*fields)
+        else:
+            query = db.session.query(cls)
+        if order_by_list:
+            for (field, order) in order_by_list:
+                query = (
+                    query.order_by(getattr(cls, field)) if order == 1 else
+                    query.order_by(desc(getattr(cls, field)))
+                )
+
+        if filter_name_dict:
+            p = parse_operator(cls, filter_name_dict)
+            query = query.filter(*p)
+
+        total_cnt = query.count()
+        start = (page - 1) * limit
+        query = query.offset(start).limit(limit)
+        instance_or_keytuple_list = query.all()
+        return instance_or_keytuple_list, total_cnt
+
+    @classmethod
+    def dump_schema(cls, items, fields, schema_class):
+        """ 用来序列化从数据库查询出来的对象
+        Args:
+            items (instance list): obj list query from mysql
+            fields (str list): fields need to dump
+            schema_class (marshmallow.Schema): marshmallow.Schema class
+        Returns:
+            items, err
+        """
+        fields = (
+            fields if fields else list(schema_class._declared_fields.keys())
+        )
+        schema = schema_class(many=True, only=fields)
+        items, err = schema.dump(items)
+        return items, err
+
+    @classmethod
+    def query_paginate_and_dump_schema(cls, page=1, limit=20, fields=None,
+                                       order_by_list=None,
+                                       filter_name_dict=None,
+                                       schema_class=None):
+        """ 分页查询并且返回dump后的对象,可以解决大部分查询问题 """
+        assert schema_class
+        items, total_cnt = cls.query_paginate(
+                page, limit, fields, order_by_list, filter_name_dict
+        )
+        items, err = cls.dump_schema(items, fields, schema_class)
+        return items, total_cnt
+
+    def __repr__(self):
+        return pformat(self.to_dict())
+
+    @cached_property
+    def column_name_set(self):
+        return set([column.name for column in self.__table__.columns])
+
+    @classmethod
+    def get_common_fields(cls, fields=None):
+        """ 防止传过来的fields含有该Model没有的字段 """
+        if not fields:
+            return []
+        table_fields_set = set(
+                [column.name for column in cls.__table__.columns]
+        )
+        return list(table_fields_set & set(fields))
+
+
+class Model(CRUDMixin, db.Model):
+    """Base model class that includes CRUD convenience methods."""
+
+    __abstract__ = True
+
+    status_remove = -1
+    status_default = 0
+    status_available = 1
+
+
+# From Mike Bayer's "Building the app" talk
+# https://speakerdeck.com/zzzeek/building-the-app
+class SurrogatePK(object):
+    """A mixin that adds a surrogate integer 'primary key' column named ``id`` to any declarative-mapped class."""
+
+    __table_args__ = {'extend_existing': True}
+
+    id = db.Column(db.Integer, primary_key=True)
+
+    @classmethod
+    def get_by_id(cls, record_id):
+        """Get record by ID."""
+        if any(
+                (isinstance(record_id, basestring) and record_id.isdigit(),
+                 isinstance(record_id, (int))),
+        ):
+            return cls.query.get(int(record_id))
+        return None
+
+
+def reference_col(tablename, nullable=False, pk_name='id', **kwargs):
+    """Column that adds primary key foreign key reference.
+
+    Usage: ::
+
+        category_id = reference_col('category')
+        category = relationship('Category', backref='categories')
+    """
+    return db.Column(
+            db.ForeignKey('{0}.{1}'.format(tablename, pk_name)),
+            nullable=nullable, **kwargs)

+ 649 - 0
walle/model/deploy.py

@@ -0,0 +1,649 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# @Author: wushuiyong
+# @Created Time : 日  1/ 1 23:43:12 2017
+# @Description:
+
+from sqlalchemy import String, Integer, Text, DateTime
+from flask import current_app
+# from flask_cache import Cache
+from datetime import datetime
+
+from walle.model.database import SurrogatePK, db, Model
+from walle.model.user import UserModel
+from walle.service.rbac.role import *
+from walle.service.extensions import permission
+
+
+# 上线单
+class TaskModel(SurrogatePK, Model):
+    __tablename__ = 'tasks'
+    current_time = datetime.now()
+    # 状态0:新建提交,1审核通过,2审核拒绝,3上线完成,4上线失败
+    status_new = 0
+    status_pass = 1
+    status_reject = 2
+    status_success = 3
+    status_fail = 4
+
+    # 表的结构:
+    id = db.Column(Integer, primary_key=True, autoincrement=True)
+    name = db.Column(String(100))
+    user_id = db.Column(Integer)
+    project_id = db.Column(Integer)
+    action = db.Column(Integer)
+    status = db.Column(Integer)
+    link_id = db.Column(String(100))
+    ex_link_id = db.Column(String(100))
+    servers = db.Column(Text)
+    commit_id = db.Column(String(40))
+    branch = db.Column(String(100))
+    tag = db.Column(String(100))
+    file_transmission_mode = db.Column(Integer)
+    file_list = db.Column(Text)
+    enable_rollback = db.Column(Integer)
+    created_at = db.Column(DateTime, default=current_time)
+    updated_at = db.Column(DateTime, default=current_time, onupdate=current_time)
+
+    taskMdl = None
+
+    def table_name(self):
+        return self.__tablename__
+
+    #
+    # def list(self, page=0, size=10, kw=''):
+    #     data = Task.query.order_by('id').offset(int(size) * int(page)).limit(size).all()
+    #     return [p.to_json() for p in data]
+    #
+    # def one(self):
+    #     project_info = Project.query.filter_by(id=self.taskMdl.get('project_id')).one().to_json()
+    #     return dict(project_info, **self.taskMdl)
+    #
+
+    def list(self, page=0, size=10, kw=None):
+        """
+        获取分页列表
+        :param page:
+        :param size:
+        :param kw:
+        :return:
+        """
+        query = TaskModel.query.filter(TaskModel.status.notin_([self.status_remove]))
+        if kw:
+            query = query.filter(TaskModel.name.like('%' + kw + '%'))
+        count = query.count()
+
+        data = query.order_by('id desc') \
+            .offset(int(size) * int(page)).limit(size) \
+            .all()
+        task_list = []
+
+        for task in data:
+            task = task.to_json()
+            project = ProjectModel().get_by_id(task['project_id']).to_dict()
+            task['project_name'] = project['name'] if project else u'未知项目'
+            task_list.append(task)
+
+        return task_list, count
+
+    def item(self, id=None):
+        """
+        获取单条记录
+        :param role_id:
+        :return:
+        """
+        id = id if id else self.id
+        data = self.query.filter(TaskModel.status.notin_([self.status_remove])).filter_by(id=id).first()
+        if not data:
+            return []
+
+        task = data.to_json()
+        project = ProjectModel().get_by_id(task['project_id']).to_dict()
+        task['project_name'] = project['name'] if project else u'未知项目'
+        task['project_info'] = project
+        return task
+
+    def add(self, *args, **kwargs):
+        # todo permission_ids need to be formated and checked
+        data = dict(*args)
+        project = TaskModel(**data)
+
+        db.session.add(project)
+        db.session.commit()
+        
+
+        if project.id:
+            self.id = project.id
+
+        return project.id
+
+    def update(self, *args, **kwargs):
+        # todo permission_ids need to be formated and checked
+        # a new type to update a model
+
+        update_data = dict(*args)
+        return super(TaskModel, self).update(**update_data)
+
+    def remove(self, id=None):
+        """
+
+        :param role_id:
+        :return:
+        """
+        id = id if id else self.id
+        self.query.filter_by(id=id).update({'status': self.status_remove})
+        ret = db.session.commit()
+        
+        return ret
+
+    def to_json(self):
+        item = {
+            'id': self.id,
+            'name': self.name,
+            'user_id': int(self.user_id),
+            'project_id': int(self.project_id),
+            'action': self.action,
+            'status': self.status,
+            'link_id': self.link_id,
+            'ex_link_id': self.ex_link_id,
+            'servers': self.servers,
+            'servers_info': ServerModel.fetch_by_id(self.servers.split(',')) if self.servers else '',
+            'commit_id': self.commit_id,
+            'branch': self.branch,
+            'tag': self.tag,
+            'file_transmission_mode': self.file_transmission_mode,
+            'file_list': self.file_list,
+            'enable_rollback': self.enable_rollback,
+            'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S'),
+            'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S'),
+        }
+        item.update(self.enable())
+        return item
+
+    def enable(self):
+        return {
+            # 'enable_update': permission.enable_uid(self.user_id) or permission.enable_role(DEVELOPER),
+            # 'enable_delete': permission.enable_uid(self.user_id) or permission.enable_role(DEVELOPER),
+            'enable_create': False,
+            # 'enable_online': permission.enable_uid(self.user_id) or permission.enable_role(DEVELOPER),
+            # 'enable_audit': permission.enable_role(DEVELOPER),
+            'enable_block': False,
+        }
+
+
+# 上线记录表
+class TaskRecordModel(Model):
+    # 表的名字:
+    __tablename__ = 'task_records'
+    current_time = datetime.now()
+
+    # 表的结构:
+    id = db.Column(Integer, primary_key=True, autoincrement=True)
+    stage = db.Column(String(20))
+    sequence = db.Column(Integer)
+    user_id = db.Column(Integer)
+    task_id = db.Column(Integer)
+    status = db.Column(Integer)
+    command = db.Column(String(200))
+    host = db.Column(String(200))
+    user = db.Column(String(200))
+    success = db.Column(String(2000))
+    error = db.Column(String(2000))
+    created_at = db.Column(DateTime, default=current_time)
+    updated_at = db.Column(DateTime, default=current_time, onupdate=current_time)
+
+    def save_record(self, stage, sequence, user_id, task_id, status, host, user, command, success=None, error=None):
+        record = TaskRecordModel(stage=stage, sequence=sequence, user_id=user_id,
+                                 task_id=task_id, status=status, host=host, user=user, command=command,
+                                 success=success, error=error)
+        db.session.add(record)
+        ret = db.session.commit()
+
+        return ret
+
+    def fetch(self, task_id):
+        data = self.query.filter_by(task_id=task_id).order_by('id desc').all()
+        return [p.to_json() for p in data]
+
+    def to_json(self):
+        return {
+            'id': self.id,
+            'stage': self.stage,
+            'sequence': self.sequence,
+            'user_id': self.user_id,
+            'task_id': self.task_id,
+            'status': self.status,
+            'host': self.host,
+            'user': self.user,
+            'command': self.command,
+            'success': self.success,
+            'error': self.error,
+            'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S'),
+            'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S'),
+        }
+
+
+# 环境级别
+class EnvironmentModel(Model):
+    # 表的名字:
+    __tablename__ = 'environments'
+
+    status_open = 1
+    status_close = 2
+    current_time = datetime.now()
+
+    # 表的结构:
+    id = db.Column(Integer, primary_key=True, autoincrement=True)
+    name = db.Column(String(20))
+    status = db.Column(Integer)
+    created_at = db.Column(DateTime, default=current_time)
+    updated_at = db.Column(DateTime, default=current_time, onupdate=current_time)
+
+    def list(self, page=0, size=10, kw=None):
+        """
+        获取分页列表
+        :param page:
+        :param size:
+        :param kw:
+        :return:
+        """
+        query = self.query.filter(EnvironmentModel.status.notin_([self.status_remove]))
+        if kw:
+            query = query.filter(EnvironmentModel.name.like('%' + kw + '%'))
+        count = query.count()
+
+        data = query.order_by('id desc').offset(int(size) * int(page)).limit(size).all()
+        env_list = [p.to_json() for p in data]
+        return env_list, count
+
+    def item(self, env_id=None):
+        """
+        获取单条记录
+        :param role_id:
+        :return:
+        """
+        data = self.query.filter(EnvironmentModel.status.notin_([self.status_remove])).filter_by(id=self.id).first()
+        return data.to_json() if data else []
+
+    def add(self, env_name):
+        # todo permission_ids need to be formated and checked
+        env = EnvironmentModel(name=env_name, status=self.status_open)
+
+        db.session.add(env)
+        db.session.commit()
+        
+        if env.id:
+            self.id = env.id
+
+        return env.id
+
+    def update(self, env_name, status, env_id=None):
+        # todo permission_ids need to be formated and checked
+        role = EnvironmentModel.query.filter_by(id=self.id).first()
+        role.name = env_name
+        role.status = status
+        ret = db.session.commit()
+        
+        return ret
+
+    def remove(self, env_id=None):
+        """
+
+        :param role_id:
+        :return:
+        """
+        self.query.filter_by(id=self.id).update({'status': self.status_remove})
+        ret = db.session.commit()
+        
+        return ret
+
+    def to_json(self):
+        item = {
+            'id': self.id,
+            'status': self.status,
+            'env_name': self.name,
+            'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S'),
+            'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S'),
+        }
+        item.update(self.enable())
+        return item
+
+    def enable(self):
+        return {
+            'enable_update': permission.enable_role(DEVELOPER),
+            'enable_delete': permission.enable_role(DEVELOPER),
+            'enable_create': False,
+            'enable_online': False,
+            'enable_audit': False,
+            'enable_block': False,
+        }
+
+
+# server
+class ServerModel(SurrogatePK, Model):
+    __tablename__ = 'servers'
+
+    current_time = datetime.now()
+
+    # 表的结构:
+    id = db.Column(Integer, primary_key=True, autoincrement=True)
+    name = db.Column(String(100))
+    host = db.Column(String(100))
+    status = db.Column(Integer)
+    created_at = db.Column(DateTime, default=current_time)
+    updated_at = db.Column(DateTime, default=current_time, onupdate=current_time)
+
+    def list(self, page=0, size=10, kw=None):
+        """
+        获取分页列表
+        :param page:
+        :param size:
+        :param kw:
+        :return:
+        """
+        query = self.query.filter(ServerModel.status.notin_([self.status_remove]))
+        if kw:
+            query = query.filter(ServerModel.name.like('%' + kw + '%'))
+        count = query.count()
+
+        data = query.order_by('id desc') \
+            .offset(int(size) * int(page)).limit(size) \
+            .all()
+        server_list = [p.to_json() for p in data]
+        return server_list, count
+
+    def item(self, id=None):
+        """
+        获取单条记录
+        :param role_id:
+        :return:
+        """
+        id = id if id else self.id
+        data = self.query.filter(ServerModel.status.notin_([self.status_remove])).filter_by(id=id).first()
+        return data.to_json() if data else []
+
+    def add(self, name, host):
+        # todo permission_ids need to be formated and checked
+        server = ServerModel(name=name, host=host, status=self.status_available)
+
+        db.session.add(server)
+        db.session.commit()
+        
+        if server.id:
+            self.id = server.id
+
+        return server.id
+
+    def update(self, name, host, id=None):
+        # todo permission_ids need to be formated and checked
+        id = id if id else self.id
+        role = ServerModel.query.filter_by(id=id).first()
+
+        if not role:
+            return False
+
+        role.name = name
+        role.host = host
+
+        ret = db.session.commit()
+        
+        return ret
+
+    def remove(self, id=None):
+        """
+
+        :param role_id:
+        :return:
+        """
+        id = id if id else self.id
+        self.query.filter_by(id=id).update({'status': self.status_remove})
+
+        ret = db.session.commit()
+        
+        return ret
+
+    @classmethod
+    def fetch_by_id(cls, ids=None):
+        """
+        用户列表
+        :param uids: []
+        :return:
+        """
+        if not ids:
+            return None
+
+        query = ServerModel.query.filter(ServerModel.id.in_(ids))
+        data = query.order_by('id desc').all()
+        return [p.to_json() for p in data]
+
+
+    def to_json(self):
+        item = {
+            'id': self.id,
+            'name': self.name,
+            'host': self.host,
+            'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S'),
+            'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S'),
+        }
+        item.update(self.enable())
+        return item
+
+    def enable(self):
+        # current_app.logger.info(dir(permission.app))
+        current_app.logger.info(permission.enable_uid(3))
+        return {
+            'enable_update': permission.enable_role(DEVELOPER),
+            # 'enable_delete': permission.enable_role(DEVELOPER),
+            'enable_create': False,
+            'enable_online': False,
+            # 'enable_audit': permission.enable_role(OWNER),
+            'enable_block': False,
+        }
+
+
+# 项目配置表
+class ProjectModel(SurrogatePK, Model):
+    # 表的名字:
+    __tablename__ = 'projects'
+    current_time = datetime.now()
+    status_close = 0
+    status_open = 1
+
+    # 表的结构:
+    id = db.Column(Integer, primary_key=True, autoincrement=True)
+    user_id = db.Column(Integer)
+    name = db.Column(String(100))
+    environment_id = db.Column(Integer)
+    space_id = db.Column(Integer)
+    status = db.Column(Integer)
+    master = db.Column(String(100))
+    version = db.Column(String(40))
+    excludes = db.Column(Text)
+    target_user = db.Column(String(50))
+    target_port = db.Column(String(20))
+    target_root = db.Column(String(200))
+    target_releases = db.Column(String(200))
+    server_ids = db.Column(Text)
+    task_vars = db.Column(Text)
+    prev_deploy = db.Column(Text)
+    post_deploy = db.Column(Text)
+    prev_release = db.Column(Text)
+    post_release = db.Column(Text)
+    keep_version_num = db.Column(Integer)
+    repo_url = db.Column(String(200))
+    repo_username = db.Column(String(50))
+    repo_password = db.Column(String(50))
+    repo_mode = db.Column(String(50))
+    repo_type = db.Column(String(10))
+    notice_type = db.Column(String(10))
+    notice_hook = db.Column(Text)
+    enable_audit = db.Column(Integer)
+
+    created_at = db.Column(DateTime, default=current_time)
+    updated_at = db.Column(DateTime, default=current_time, onupdate=current_time)
+
+    def list(self, page=0, size=10, kw=None, space_id=None, environment_id=None):
+        """
+        获取分页列表
+        :param page:
+        :param size:
+        :return:
+        """
+        query = self.query.filter(ProjectModel.status.notin_([self.status_remove]))
+        if kw:
+            query = query.filter(ProjectModel.name.like('%' + kw + '%'))
+
+        if environment_id:
+            query = query.filter_by(environment_id=environment_id)
+        if space_id:
+            query = query.filter_by(space_id=space_id)
+        count = query.count()
+        data = query.order_by('id desc').offset(int(size) * int(page)).limit(size).all()
+        list = [p.to_json() for p in data]
+        return list, count
+
+    def item(self, id=None):
+        """
+        获取单条记录
+        :param role_id:
+        :return:
+        """
+        id = id if id else self.id
+        data = self.query.filter(ProjectModel.status.notin_([self.status_remove])).filter_by(id=id).first()
+
+        if not data:
+            return []
+
+        data = data.to_json()
+
+        server_ids = data['server_ids']
+        data['servers_info'] = ServerModel.fetch_by_id(map(int, server_ids.split(',')))
+        return data
+
+    def add(self, *args, **kwargs):
+        # todo permission_ids need to be formated and checked
+        data = dict(*args)
+        project = ProjectModel(**data)
+
+        db.session.add(project)
+        db.session.commit()
+        
+        self.id = project.id
+        return self.id
+
+    def update(self, *args, **kwargs):
+        # todo permission_ids need to be formated and checked
+        # a new type to update a model
+
+        update_data = dict(*args)
+        return super(ProjectModel, self).update(**update_data)
+
+    def remove(self, role_id=None):
+        """
+
+        :param role_id:
+        :return:
+        """
+        role_id = role_id if role_id else self.id
+        ProjectModel.query.filter_by(id=role_id).update({'status': self.status_remove})
+
+        ret = db.session.commit()
+        
+        return ret
+
+    def to_json(self):
+        item = {
+            'id': self.id,
+            'user_id': self.user_id,
+            'name': self.name,
+            'environment_id': self.environment_id,
+            'space_id': self.space_id,
+            'status': self.status,
+            'master': UserModel.fetch_by_uid(self.master.split(',')) if self.master else '',
+            'version': self.version,
+            'excludes': self.excludes,
+            'target_user': self.target_user,
+            'target_port': self.target_port,
+            'target_root': self.target_root,
+            'target_releases': self.target_releases,
+            'server_ids': self.server_ids,
+            'task_vars': self.task_vars,
+            'prev_deploy': self.prev_deploy,
+            'post_deploy': self.post_deploy,
+            'prev_release': self.prev_release,
+            'post_release': self.post_release,
+            'keep_version_num': self.keep_version_num,
+            'repo_url': self.repo_url,
+            'repo_username': self.repo_username,
+            'repo_password': self.repo_password,
+            'repo_mode': self.repo_mode,
+            'repo_type': self.repo_type,
+            'notice_type': self.notice_type,
+            'notice_hook': self.notice_hook,
+            'enable_audit': self.enable_audit,
+            'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S'),
+            'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S'),
+        }
+        item.update(self.enable())
+        return item
+
+    def enable(self):
+        current_app.logger.info(self.id)
+        return {
+            'enable_update': permission.is_gte_develop_or_uid(self.user_id),
+            'enable_delete': permission.enable_uid(self.user_id) or permission.enable_role(DEVELOPER),
+            'enable_create': False,
+            'enable_online': False,
+            'enable_audit': False,
+            'enable_block': False,
+        }
+
+
+class TagModel(SurrogatePK, Model):
+    # 表的名字:
+    __tablename__ = 'tags'
+
+    current_time = datetime.now()
+
+    # 表的结构:
+    id = db.Column(Integer, primary_key=True, autoincrement=True)
+    name = db.Column(String(30))
+    label = db.Column(String(30))
+    label_id = db.Column(Integer, default=0)
+    # users = db.relationship('Group', backref='group', lazy='dynamic')
+    created_at = db.Column(DateTime, default=current_time)
+    updated_at = db.Column(DateTime, default=current_time, onupdate=current_time)
+
+    def list(self):
+        data = TagModel.query.filter(TagModel.status.notin_([self.status_remove])).filter_by(id=1).first()
+        # # return data.tag.count('*').to_json()
+        # # print(data)
+        # return []
+        return data.to_json() if data else []
+
+    def remove(self, tag_id):
+        """
+
+        :param role_id:
+        :return:
+        """
+        TagModel.query.filter_by(id=tag_id).update({'status': self.status_remove})
+
+        ret = db.session.commit()
+        
+        return ret
+
+    def to_json(self):
+        # user_ids = []
+        # for user in self.users.all():
+        #     user_ids.append(user.user_id)
+        return {
+            'id': self.id,
+            'group_id': self.id,
+            'group_name': self.name,
+            # 'users': user_ids,
+            # 'user_ids': user_ids,
+            'label': self.label,
+            'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S'),
+            'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S'),
+        }

+ 58 - 0
walle/model/tag.py

@@ -0,0 +1,58 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# @Author: wushuiyong
+# @Created Time : 日  1/ 1 23:43:12 2017
+# @Description:
+
+from sqlalchemy import String, Integer, DateTime
+
+# from flask_cache import Cache
+from datetime import datetime
+
+from walle.model.database import SurrogatePK, db, Model
+
+class TagModel(SurrogatePK, Model):
+    # 表的名字:
+    __tablename__ = 'tags'
+
+    current_time = datetime.now()
+
+    # 表的结构:
+    id = db.Column(Integer, primary_key=True, autoincrement=True)
+    name = db.Column(String(30))
+    label = db.Column(String(30))
+    label_id = db.Column(Integer, default=0)
+    # users = db.relationship('Group', backref='group', lazy='dynamic')
+    created_at = db.Column(DateTime, default=current_time)
+    updated_at = db.Column(DateTime, default=current_time, onupdate=current_time)
+
+    def list(self):
+        data = TagModel.query.filter(TagModel.status.notin_([self.status_remove])).filter_by(id=1).first()
+        # # return data.tag.count('*').to_json()
+        # # print(data)
+        # return []
+        return data.to_json() if data else []
+
+    def remove(self, tag_id):
+        """
+
+        :param role_id:
+        :return:
+        """
+        TagModel.query.filter_by(id=tag_id).update({'status': self.status_remove})
+        return db.session.commit()
+
+    def to_json(self):
+        # user_ids = []
+        # for user in self.users.all():
+        #     user_ids.append(user.user_id)
+        return {
+            'id': self.id,
+            'group_id': self.id,
+            'group_name': self.name,
+            # 'users': user_ids,
+            # 'user_ids': user_ids,
+            'label': self.label,
+            'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S'),
+            'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S'),
+        }

+ 884 - 0
walle/model/user.py

@@ -0,0 +1,884 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# @Author: wushuiyong
+# @Created Time : 日  1/ 1 23:43:12 2017
+# @Description:
+
+from flask_login import UserMixin
+from sqlalchemy import String, Integer, DateTime, or_
+from werkzeug.security import check_password_hash, generate_password_hash
+
+# from flask_cache import Cache
+from datetime import datetime
+from walle.service.extensions import login_manager
+from walle.model.database import SurrogatePK, db, Model
+from walle.model.tag import TagModel
+from sqlalchemy.orm import aliased
+from walle.service.rbac.access import Access as AccessRbac
+from flask import current_app, session, abort
+from walle.service.rbac.role import *
+from walle.service.error import WalleError
+from flask_login import current_user as g
+from walle.service.extensions import permission
+
+import walle.model
+
+class UserModel(UserMixin, SurrogatePK, Model):
+    # 表的名字:
+    __tablename__ = 'users'
+    status_active = 1
+    status_blocked = 2
+
+    current_time = datetime.now()
+    password_hash = 'sadfsfkk'
+    # 表的结构:
+    id = db.Column(Integer, primary_key=True, autoincrement=True)
+    username = db.Column(String(50))
+    is_email_verified = db.Column(Integer, default=0)
+    email = db.Column(String(50), unique=True, nullable=False)
+    password = db.Column(String(50), nullable=False)
+    avatar = db.Column(String(100))
+    role = db.Column(String(10))
+    status = db.Column(Integer, default=1)
+    last_space = db.Column(Integer, default=0)
+    # role_info = relationship("walle.model.user.RoleModel", back_populates="users")
+    created_at = db.Column(DateTime, default=current_time)
+    updated_at = db.Column(DateTime, default=current_time, onupdate=current_time)
+
+    status_mapping = {
+        -1: '删除',
+        0: '新建',
+        1: '正常',
+        2: '冻结',
+    }
+
+    '''
+    current_user 基础方法
+      "__abstract__",
+      "__class__",
+      "__delattr__",
+      "__dict__",
+      "__doc__",
+      "__eq__",
+      "__format__",
+      "__getattribute__",
+      "__hash__",
+      "__init__",
+      "__mapper__",
+      "__module__",
+      "__ne__",
+      "__new__",
+      "__reduce__",
+      "__reduce_ex__",
+      "__repr__",
+      "__setattr__",
+      "__sizeof__",
+      "__str__",
+      "__subclasshook__",
+      "__table__",
+      "__table_args__",
+      "__tablename__",
+      "__weakref__",
+      "_cached_tablename",
+      "_decl_class_registry",
+      "_sa_class_manager",
+      "_sa_instance_state",
+      "avatar",
+      "avatar_url",
+      "block_active",
+      "column_name_set",
+      "create",
+      "create_from_dict",
+      "create_or_update",
+      "created_at",
+      "current_time",
+      "delete",
+      "dump_schema",
+      "email",
+      "enable",
+      "fetch_access_list_by_role_id",
+      "fetch_by_uid",
+      "general_password",
+      "get_by_id",
+      "get_common_fields",
+      "get_id",
+      "id",
+      "is_active",
+      "is_anonymous",
+      "is_authenticated",
+      "is_email_verified",
+      "item",
+      "list",
+      "metadata",
+      "password",
+      "password_hash",
+      "query",
+      "query_class",
+      "query_paginate",
+      "query_paginate_and_dump_schema",
+      "remove",
+      "save",
+      "set_password",
+      "status",
+      "status_active",
+      "status_available",
+      "status_blocked",
+      "status_default",
+      "status_mapping",
+      "status_remove",
+      "to_dict",
+      "to_json",
+      "uid2name",
+      "update",
+      "update_avatar",
+      "update_name_pwd",
+      "updated_at",
+      "username",
+      "verify_password"
+    '''
+    def add(self, *args, **kwargs):
+        data = dict(*args)
+        user = UserModel(**data)
+
+        db.session.add(user)
+        db.session.commit()
+        return user
+
+    def item(self, user_id=None):
+        """
+        获取单条记录
+        :param role_id:
+        :return:
+        """
+        data = self.query.filter_by(id=self.id).filter(UserModel.status.notin_([self.status_remove])).first()
+        return data.to_json() if data else []
+
+
+    def update(self, *args, **kwargs):
+        # todo permission_ids need to be formated and checked
+        # a new type to update a model
+
+        update_data = dict(*args)
+        return super(UserModel, self).update(**update_data)
+
+    def update_avatar(self, avatar):
+        d = {'avatar': avatar}
+        user = self.query.get(self.id).update(**d)
+        current_app.logger.info(user)
+
+    def update_name_pwd(self, username, password=None):
+        # todo permission_ids need to be formated and checked
+        user = self.query.filter_by(id=self.id).first()
+        user.username = username
+        if password:
+            self.set_password(password)
+
+        db.session.commit()
+        return user.to_json()
+
+    def block_active(self, status):
+        user = self.query.filter_by(id=self.id).first()
+        user.status = status
+        db.session.commit()
+        return user.to_json()
+
+    def remove(self):
+        """
+
+        :param role_id:
+        :return:
+        """
+        self.query.filter_by(id=self.id).update({'status': self.status_remove})
+
+        ret = db.session.commit()
+
+        return ret
+
+    def verify_password(self, password):
+        """
+        检查密码是否正确
+        :param password:
+        :return:
+        """
+        if self.password is None:
+            return False
+        return check_password_hash(self.password, password)
+
+    def set_password(self, password):
+        """Set password."""
+        self.password = generate_password_hash(password)
+
+    def general_password(self, password):
+        """
+        检查密码是否正确
+        :param password:
+        :return:
+        """
+        self.password = generate_password_hash(password)
+        return generate_password_hash(password)
+
+    def fetch_access_list_by_role_id(self, role_id):
+        module = aliased(MenuModel)
+        controller = aliased(MenuModel)
+        action = aliased(MenuModel)
+        role = RoleModel.query.get(role_id)
+        access_ids = role.access_ids.split(',')
+
+        data = db.session \
+            .query(controller.name_en, controller.name_cn,
+                   action.name_en, action.name_cn) \
+            .outerjoin(action, action.pid == controller.id) \
+            .filter(module.type == MenuModel.type_module) \
+            .filter(controller.id.in_(access_ids)) \
+            .filter(action.id.in_(access_ids)) \
+            .all()
+
+        return [AccessRbac.resource(a_en, c_en) for c_en, c_cn, a_en, a_cn in data if c_en and a_en]
+
+    def is_authenticated(self):
+        return True
+
+    def is_active(self):
+        return True
+
+    def is_anonymous(self):
+        return False
+
+    def get_id(self):
+        try:
+            return unicode(self.id)  # python 2
+        except NameError:
+            return str(self.id)  # python 3
+
+    def list(self, uids=[], page=0, size=10, kw=None):
+        """
+        获取分页列表
+        :param page:
+        :param size:
+        :return:
+        """
+        query = UserModel.query.filter(UserModel.status.notin_([self.status_remove]))
+        if kw:
+            query = query.filter(or_(UserModel.username.like('%' + kw + '%'), UserModel.email.like('%' + kw + '%')))
+        if uids:
+            query = query.filter(UserModel.id.in_(uids))
+
+        count = query.count()
+        data = query.order_by('id desc').offset(int(size) * int(page)).limit(size).all()
+        user_list = [p.to_json() for p in data]
+        return user_list, count
+
+    def has_spaces(self):
+        return MemberModel().spaces(user_id=self.id)
+
+    @classmethod
+    def fresh_session(cls):
+        spaces = current_user.has_spaces()
+
+        # 1.无空间权限
+        if not spaces:
+            raise WalleError(Code.space_empty)
+
+        default_space = spaces.keys()[0]
+        # 2.第一次登录无空间
+        if not current_user.last_space:
+            current_user.last_space = default_space
+            current_user.save()
+            session['space_id'] = default_space
+            session['space_info'] = spaces[session['space_id']]
+
+        # 3.空间权限有修改
+        if current_user.last_space and current_user.last_space not in spaces.keys():
+            raise WalleError(Code.space_error)
+
+        session['space_id'] = current_user.last_space
+        session['space_info'] = spaces[current_user.last_space]
+        session['space_list'] = spaces.values()
+
+        current_app.logger.info('============ SecurityResource.__init__ ============')
+
+
+    @classmethod
+    def avatar_url(cls, avatar):
+        avatar = avatar if avatar else 'default.jpg'
+        return '/' + current_app.config['AVATAR_PATH'] + avatar
+
+    @classmethod
+    def fetch_by_uid(cls, uids=None):
+        """
+        用户列表
+        :param uids: []
+        :return:
+        """
+        if not uids:
+            return []
+
+        query = UserModel.query.filter(UserModel.id.in_(uids)).filter(UserModel.status.notin_([cls.status_remove]))
+        data = query.order_by('id desc').all()
+        return [p.to_json() for p in data]
+
+    @classmethod
+    def uid2name(cls, data):
+        """
+        把uid转换成名字
+        :param data: [{'user_id':1, 'xx':'yy'}] 至少包含user_id
+        :return:
+        """
+        user_ids = []
+        uid2name = {}
+        for items in data:
+            user_ids.append(items.user_id)
+        user_info = cls.fetch_by_uid(uids=user_ids)
+
+        for user in user_info:
+            uid2name[user['id']] = user['username']
+        return uid2name
+
+    def to_json(self):
+        item = {
+            'id': int(self.id),
+            'user_id': int(self.id),
+            'username': self.username,
+            'is_email_verified': self.is_email_verified,
+            'email': self.email,
+            'avatar': self.avatar_url(self.avatar),
+            # TODO 当前登录用户的空间
+            # 'role_id': self.role_id,
+            'status': self.status_mapping[self.status],
+            'last_space': self.last_space,
+            # 'status': self.status,
+            # 'role_name': self.role_id,
+            'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S'),
+            'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S'),
+        }
+        item.update(self.enable())
+        return item
+
+    def enable(self):
+        return {
+            'enable_update': permission.enable_role(DEVELOPER),
+            'enable_delete': permission.enable_role(DEVELOPER),
+            'enable_create': False,
+            'enable_online': False,
+            'enable_audit': False,
+            'enable_block': False,
+        }
+
+class MenuModel(SurrogatePK, Model):
+    __tablename__ = 'menus'
+
+    type_module = 'module'
+    type_controller = 'controller'
+    type_action = 'action'
+
+    status_open = 1
+    status_close = 2
+    current_time = datetime.now()
+
+    # 表的结构:
+    id = db.Column(Integer, primary_key=True, autoincrement=True)
+    name_cn = db.Column(String(30))
+    name_en = db.Column(String(30))
+    pid = db.Column(Integer)
+    type = db.Column(String(30))
+    sequence = db.Column(Integer)
+    archive = db.Column(Integer)
+    icon = db.Column(String(30))
+    url = db.Column(String(30))
+    visible = db.Column(Integer)
+    role = db.Column(Integer)
+    created_at = db.Column(DateTime, default=current_time)
+    updated_at = db.Column(DateTime, default=current_time, onupdate=current_time)
+
+    def menu(self, role):
+        data = {}
+        filters = {
+            MenuModel.visible == 1,
+            MenuModel.role >= role
+        }
+        query = self.query \
+            .filter(*filters) \
+            .order_by('sequence asc') \
+            .all()
+        for item in query:
+            if item.type == self.type_module:
+                module = {
+                    'title': item.name_cn,
+                    'icon': item.icon,
+                    'sub_menu': [],
+                }
+                if item.url:
+                    module['url'] = RoleModel.menu_url(item.url)
+                data[item.id] = module
+            elif item.type == self.type_controller:
+                data[item.pid]['sub_menu'].append({
+                    'title': item.name_cn,
+                    'icon': item.icon,
+                    'url': RoleModel.menu_url(item.url),
+                })
+
+        return data.values()
+
+    def list(self):
+        """
+        获取分页列表
+        :param page:
+        :param size:
+        :param kw:
+        :return:
+        """
+        menus_module = {}
+        menus_controller = {}
+        module = aliased(MenuModel)
+        controller = aliased(MenuModel)
+        action = aliased(MenuModel)
+
+        data = db.session.query(module.id, module.name_cn, controller.id, controller.name_cn, action.id, action.name_cn) \
+            .outerjoin(controller, controller.pid == module.id) \
+            .outerjoin(action, action.pid == controller.id) \
+            .filter(module.type == self.type_module) \
+            .all()
+        for m_id, m_name, c_id, c_name, a_id, a_name in data:
+            # module
+            if not menus_module.has_key(m_id):
+                menus_module[m_id] = {
+                    'id': m_id,
+                    'title': m_name,
+                    'sub_menu': {},
+                }
+            # controller
+            if not menus_module[m_id]['sub_menu'].has_key(c_id) and c_name:
+                menus_module[m_id]['sub_menu'][c_id] = {
+                    'id': c_id,
+                    'title': c_name,
+                    'sub_menu': {},
+                }
+            # action
+            if not menus_controller.has_key(c_id):
+                menus_controller[c_id] = []
+            if a_name:
+                menus_controller[c_id].append({
+                    'id': a_id,
+                    'title': a_name,
+                })
+        menus = []
+        for m_id, m_info in menus_module.items():
+            for c_id, c_info in m_info['sub_menu'].items():
+                m_info['sub_menu'][c_id]['sub_menu'] = menus_controller[c_id]
+            menus.append({
+                'id': m_id,
+                'title': m_info['title'],
+                'sub_menu': m_info['sub_menu'].values(),
+            })
+
+        return menus
+
+    def to_json(self):
+        return {
+            'id': self.id,
+            'name_cn': self.name_cn,
+            'name_en': self.name_en,
+            'pid': self.pid,
+            'type': self.type,
+            'sequence': self.sequence,
+            'archive': self.archive,
+            'icon': self.icon,
+            'url': self.url,
+            'visible': self.visible,
+            'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S'),
+            'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S'),
+        }
+
+
+class RoleModel(object):
+    _role_super = 'SUPER'
+
+    _role_owner = 'OWNER'
+
+    _role_master = 'MASTER'
+
+    _role_developer = 'DEVELOPER'
+
+    _role_reporter = 'REPORTER'
+
+    @classmethod
+    def list(cls):
+        roles = [
+            {'id': cls._role_super, 'name': '超级管理员'},
+            {'id': cls._role_owner, 'name': '空间所有者'},
+            {'id': cls._role_master, 'name': '项目管理员'},
+            {'id': cls._role_developer, 'name': '开发者'},
+            {'id': cls._role_reporter, 'name': '访客'},
+        ]
+        return roles, len(roles)
+
+    @classmethod
+    def item(cls, role_id):
+        return None
+
+    @classmethod
+    def menu_url(cls, url):
+        if url == '/':
+            return url
+        prefix = 'admin' if g.role == SUPER else session['space_info']['name']
+
+        return '/' + prefix + url
+
+
+# 项目配置表
+class MemberModel(SurrogatePK, Model):
+    __tablename__ = 'members'
+
+    current_time = datetime.now()
+    group_id = None
+    project_id = None
+
+    source_type_project = 'project'
+    source_type_group = 'group'
+
+    # 表的结构:
+    id = db.Column(Integer, primary_key=True, autoincrement=True)
+    user_id = db.Column(Integer, db.ForeignKey('users.id'))
+    source_id = db.Column(Integer)
+    source_type = db.Column(String(10))
+    access_level = db.Column(String(10))
+    status = db.Column(Integer)
+    created_at = db.Column(DateTime, default=current_time)
+    updated_at = db.Column(DateTime, default=current_time, onupdate=current_time)
+    group_name = None
+
+    # TODO group id全局化
+
+    def spaces(self, user_id=None):
+        """
+        获取分页列表
+        :param page:
+        :param size:
+        :return:
+        """
+        filters = {
+            MemberModel.status.notin_([self.status_remove]),
+            MemberModel.source_type == self.source_type_group
+        }
+        query = self.query.filter(*filters).with_labels().with_entities(MemberModel.source_id, MemberModel.access_level, SpaceModel.name)
+        if user_id:
+            query = query.filter_by(user_id=user_id)
+
+        query = query.join(SpaceModel, SpaceModel.id==MemberModel.source_id)
+
+        spaces = query.all()
+        current_app.logger.info(spaces)
+        return {space[0]: {'id': space[0], 'role': space[1], 'name': space[2]} for space in spaces}
+
+
+    def projects(self, user_id=None, space_id=None):
+        """
+        获取分页列表
+        :param page:
+        :param size:
+        :return:
+        """
+        filters = {
+            MemberModel.status.notin_([self.status_remove]),
+            MemberModel.source_type == self.source_type_project
+        }
+        query = self.query.filter(*filters)
+        if user_id:
+            query = query.filter_by(user_id=user_id)
+
+        # if project_id:
+        #     query = query.filter_by(source_id=project_id)
+
+        projects = query.all()
+        current_app.logger.info(projects)
+
+        return projects
+
+        group, count = MemberModel.query_paginate(page=page, limit=size, filter_name_dict=filters)
+
+        list = [p.to_json() for p in group]
+        return list, count
+
+    def add(self, space_name, members):
+        """
+
+        :param space_name:
+        :param members: [{'user_id': 1, 'project_id': 2}]
+        :return:
+        """
+        tag = TagModel(name=space_name, label='user_group')
+        db.session.add(tag)
+        db.session.commit()
+
+
+        for member in members:
+            user_group = MemberModel(group_id=tag.id, user_id=member['user_id'], project_id=member['project_id'])
+            db.session.add(user_group)
+
+        db.session.commit()
+
+        if tag.id:
+            self.group_id = tag.id
+
+        return tag.id
+
+    def update_group(self, members, group_name=None):
+        # 修复空间名称
+        if group_name:
+            SpaceModel(id=self.group_id).update({'name': group_name})
+        # # 修改tag信息
+        # if group_name:
+        #     tag_model = TagModel.query.filter_by(label='user_group').filter_by(id=self.group_id).first()
+        #     if tag_model.name != group_name:
+        #         tag_model.name = group_name
+
+        # 修改用户组成员
+        # clean up
+        filters = {
+            MemberModel.source_id == self.group_id,
+            MemberModel.source_type == self.source_type_group,
+        }
+        MemberModel.query.filter(*filters).delete()
+
+        # insert all
+        for member in members:
+            update = {
+                'user_id': member['user_id'],
+                'source_id': self.group_id,
+                'source_type': self.source_type_group,
+                'access_level': member['role'].upper(),
+                'status': self.status_available,
+            }
+            m = MemberModel(**update)
+            db.session.add(m)
+
+
+        ret = db.session.commit()
+
+        return ret
+
+    def update_project(self, project_id, members, group_name=None):
+        space_info = walle.model.deploy.ProjectModel.query.filter_by(id=project_id).first().to_json()
+        group_model = self.members(group_id=space_info['space_id'])
+        user_update = []
+
+        for member in members:
+            user_update.append(member['user_id'])
+
+        # project新增用户是否在space's group中,无则抛出
+        if list(set(user_update).difference(set(group_model['user_ids']))):
+            raise ValueError('用户不存在')
+
+        # 修改用户组成员
+        # clean up
+        filters = {
+            MemberModel.source_id == project_id,
+            MemberModel.source_type == self.source_type_project,
+        }
+        MemberModel.query.filter(*filters).delete()
+
+        # insert all
+        for member in members:
+            insert = {
+                'user_id': member['user_id'],
+                'source_id': project_id,
+                'source_type': self.source_type_project,
+                'access_level': member['role'].upper(),
+                'status': self.status_available,
+            }
+            group = MemberModel(**insert)
+            db.session.add(group)
+
+        ret = db.session.commit()
+
+        return ret
+
+    def members(self, group_id=None, project_id=None):
+        """
+        获取单条记录
+        :param role_id:
+        :return:
+        """
+        group_id = group_id if group_id else self.group_id
+        project_id = project_id if project_id else self.project_id
+        source_id = group_id if group_id else project_id
+        source_type = self.source_type_group if group_id else self.source_type_project
+        filters = {
+            'status': {'nin': [self.status_remove]},
+            'source_id': {'=': source_id},
+            'source_type': {'=': source_type},
+        }
+
+        # TODO
+        page = 1
+        size = 10
+        groups, count = MemberModel.query_paginate(page=page, limit=size, filter_name_dict=filters)
+
+        user_ids = []
+        user_role = members = {}
+        current_app.logger.info(groups)
+
+        for group_info in groups:
+            user_ids.append(group_info.user_id)
+            # TODO
+            user_role[group_info.user_id] = group_info.access_level
+
+        current_app.logger.info(user_ids)
+        user_model = UserModel()
+        user_info = user_model.fetch_by_uid(uids=set(user_ids))
+        if user_info:
+            for user in user_info:
+                if user_role.has_key(user['id']):
+                    user['role'] = user_role[user['id']]
+
+        members['user_ids'] = user_ids
+        members['members'] = user_info
+        members['users'] = len(user_ids)
+        return members
+
+    def remove(self, group_id=None, user_id=None, project_id=None):
+        """
+
+        :param role_id:
+        :return:
+        """
+        if group_id:
+            MemberModel.query.filter_by(group_id=group_id).update({'status': self.status_remove})
+        elif user_id:
+            MemberModel.query.filter_by(user_id=user_id).update({'status': self.status_remove})
+        elif self.group_id:
+            MemberModel.query.filter_by(group_id=self.group_id).update({'status': self.status_remove})
+        elif project_id:
+            MemberModel.query.filter_by(project_id=project_id).update({'status': self.status_remove})
+
+        ret = db.session.commit()
+
+        return ret
+
+    def to_json(self):
+        return {
+            'id': self.id,
+            'user_id': self.user_id,
+            'group_id': self.group_id,
+            'group_name': self.group_name,
+            'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S'),
+            'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S'),
+        }
+
+
+# 项目配置表
+class SpaceModel(SurrogatePK, Model):
+    # 表的名字:
+    __tablename__ = 'spaces'
+    current_time = datetime.now()
+    status_close = 0
+    status_open = 1
+
+    # 表的结构:
+    id = db.Column(Integer, primary_key=True, autoincrement=True)
+    user_id = db.Column(Integer)
+    name = db.Column(String(100))
+    status = db.Column(Integer)
+
+    created_at = db.Column(DateTime, default=current_time)
+    updated_at = db.Column(DateTime, default=current_time, onupdate=current_time)
+
+    def list(self, page=0, size=10, kw=None):
+        """
+        获取分页列表
+        :param page:
+        :param size:
+        :return:
+        """
+        query = self.query.filter(SpaceModel.status.notin_([self.status_remove]))
+        if kw:
+            query = query.filter(SpaceModel.name.like('%' + kw + '%'))
+
+        # TODO 如果是超管,可以全量,否则需要过滤自己有权限的空间列表
+        if g.role <> SUPER:
+            query = query.filter_by(user_id=g.id)
+        count = query.count()
+        data = query.order_by('id desc').offset(int(size) * int(page)).limit(size).all()
+
+        uid2name = UserModel.uid2name(data=data)
+        list = [p.to_json(uid2name) for p in data]
+        return list, count
+
+    def item(self, id=None):
+        """
+        获取单条记录
+        :param role_id:
+        :return:
+        """
+        id = id if id else self.id
+        data = self.query.filter_by(id=id).first()
+        members = MemberModel(group_id=id).members()
+
+        if not data:
+            return []
+
+        data = data.to_json()
+
+        return dict(data, **members)
+
+    def add(self, *args, **kwargs):
+        # todo permission_ids need to be formated and checked
+        data = dict(*args)
+
+        # tag = TagModel(name=data['name'], label='user_group')
+        # db.session.add(tag)
+        # db.session.commit()
+        data = dict(*args)
+        space = SpaceModel(**data)
+        db.session.add(space)
+        db.session.commit()
+
+
+        self.id = space.id
+        return self.id
+
+    def update(self, *args, **kwargs):
+        # todo permission_ids need to be formated and checked
+        # a new type to update a model
+
+        update_data = dict(*args)
+        return super(SpaceModel, self).update(**update_data)
+
+    def remove(self, space_id=None):
+        """
+
+        :param space_id:
+        :return:
+        """
+        space_id = space_id if space_id else self.id
+        SpaceModel.query.filter_by(id=space_id).update({'status': self.status_remove})
+
+        ret = db.session.commit()
+
+        return ret
+
+    def to_json(self, uid2name=None):
+        item = {
+            'id': self.id,
+            'user_id': self.user_id,
+            'user_name': uid2name[self.user_id] if uid2name and uid2name.has_key(self.user_id) else '',
+            # TODO
+            'group_id': 'self.group_id',
+            'name': self.name,
+            'status': self.status,
+            'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S'),
+            'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S'),
+        }
+        item.update(self.enable())
+        return item
+
+    def enable(self):
+        return {
+            'enable_update': permission.enable_uid(self.user_id) or permission.enable_role(OWNER),
+            'enable_delete': permission.enable_uid(self.user_id) or permission.enable_role(OWNER),
+            'enable_create': False,
+            'enable_online': False,
+            'enable_audit': False,
+            'enable_block': permission.enable_role(MASTER),
+        }
+

+ 8 - 0
walle/service/__init__.py

@@ -0,0 +1,8 @@
+# -*- coding: utf-8 -*-
+"""
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2017-06-14 15:16:08
+    :author: wushuiyong@walle-web.io
+"""

+ 39 - 0
walle/service/code.py

@@ -0,0 +1,39 @@
+# -*- coding: utf-8 -*-
+"""
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2018-11-11 19:49:37
+    :author: wushuiyong@walle-web.io
+"""
+
+
+class Code():
+
+    #: 1xxx 表示用户相关: 登录, 权限
+    #: 未登录, 大概是永远不会变了
+    unlogin = 1000
+
+    #: 无此权限
+    not_allow = 1001
+
+    #: 尚未开通空间
+    space_empty = 1002
+
+    #: 无此空间权限
+    space_error = 1003
+
+
+    #: 2xxx 表示参数错误
+    params_error = 2000
+
+
+    code_msg = {
+        unlogin: '未登录',
+        not_allow: '无此权限',
+        params_error: '参数错误',
+        space_empty: '尚未开通空间, 请联系空间负责人加入空间',
+        space_error: '无此空间权限',
+    }
+
+

+ 386 - 0
walle/service/deployer.py

@@ -0,0 +1,386 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# @Author: wushuiyong
+# @Created Time : 日  1/ 1 23:43:12 2017
+# @Description:
+
+
+import time
+from datetime import datetime
+
+import os
+# from fabric import context_managers, colors
+from flask import current_app
+
+from walle.model import deploy as TaskModel
+from walle.service.waller import Waller
+from walle.model.deploy import ProjectModel
+
+
+# import fabric2.exceptions.GroupException
+
+
+class Deployer:
+
+    '''
+    序列号
+    '''
+    stage = '0'
+
+    sequence = 0
+    stage_prev_deploy = 'prev_deploy'
+    stage_deploy = 'deploy'
+    stage_post_deploy = 'post_deploy'
+
+    stage_prev_release = 'prev_release'
+    stage_release = 'release'
+    stage_post_release = 'post_release'
+
+    task_id = '0'
+    user_id = '0'
+    taskMdl = None
+    TaskRecord = None
+
+    version = datetime.now().strftime('%Y%m%d%H%M%s')
+    project_name = 'walden'
+    dir_codebase = '/tmp/walle/codebase/'
+    dir_codebase_project = dir_codebase + project_name
+
+    # 定义远程机器
+    # env.hosts = ['172.16.0.231', '172.16.0.177']
+
+    dir_release = None
+    dir_webroot = None
+
+    connections, success, errors = {}, {}, {}
+    release_version_tar, release_version = None, None
+    local, websocket = None, None
+
+    def __init__(self, task_id=None, project_id=None, websocket=None):
+        self.local = Waller(host=current_app.config.get('LOCAL_SERVER_HOST'),
+                            user=current_app.config.get('LOCAL_SERVER_USER'),
+                            port=current_app.config.get('LOCAL_SERVER_PORT'))
+        self.TaskRecord = TaskModel.TaskRecordModel()
+        if websocket:
+            websocket.send_updates(__name__)
+            self.websocket = websocket
+        if task_id:
+            self.task_id = task_id
+            self.taskMdl = TaskModel.TaskModel().item(self.task_id)
+            self.user_id = self.taskMdl.get('user_id')
+            self.servers = self.taskMdl.get('servers_info')
+            self.task = self.taskMdl.get('target_user')
+            self.project_info = self.taskMdl.get('project_info')
+        if project_id:
+            self.project_id = project_id
+            self.project_info = ProjectModel(id=project_id).item()
+
+    def config(self):
+        return {'task_id': self.task_id, 'user_id': self.user_id, 'stage': self.stage, 'sequence': self.sequence,
+                'websocket': self.websocket}
+
+    # ===================== fabric ================
+    # SocketHandler
+    def prev_deploy(self):
+        '''
+        1.代码检出前要做的基础工作
+        - 检查 当前用户
+        - 检查 python 版本
+        - 检查 git 版本
+        - 检查 目录是否存在
+        - 用户自定义命令
+
+        :return:
+        '''
+        self.stage = self.stage_prev_deploy
+        self.sequence = 1
+
+        # TODO remove
+        # result = self.local.run('sleep 30', wenv=self.config())
+
+        # 检查 当前用户
+        command = 'whoami'
+        self.websocket.send_updates(command)
+        current_app.logger.info(command)
+
+        result = self.local.run(command, wenv=self.config())
+
+        # 检查 python 版本
+        command = 'python --version'
+        result = self.local.run(command, wenv=self.config())
+        current_app.logger.info(command)
+
+        # 检查 git 版本
+        command = 'git --version'
+        result = self.local.run(command, wenv=self.config())
+        current_app.logger.info(command)
+
+        # 检查 目录是否存在
+        command = 'mkdir -p %s' % (self.dir_codebase_project)
+        # TODO remove
+        current_app.logger.info(command)
+        result = self.local.run(command, wenv=self.config())
+
+        # 用户自定义命令
+        command = self.project_info['prev_deploy']
+        current_app.logger.info(command)
+        with self.local.cd(self.dir_codebase_project):
+            result = self.local.run(command, wenv=self.config())
+
+            # SocketHandler.send_to_all({
+            #     'type': 'user',
+            #     'id': 33,
+            #     'host': env.host_string,
+            #     'command': command,
+            #     'message': result.stdout,
+            # })
+
+    def deploy(self):
+        '''
+        2.检出代码
+
+        :param project_name:
+        :return:
+        '''
+        self.stage = self.stage_deploy
+        self.sequence = 2
+
+        current_app.logger.info('git dir: %s', self.dir_codebase_project + '/.git')
+        # 如果项目底下有 .git 目录则认为项目完整,可以直接检出代码
+        # TODO 不标准
+        if os.path.exists(self.dir_codebase_project + '/.git'):
+            with self.local.cd(self.dir_codebase_project):
+                command = 'pwd && git pull'
+                result = self.local.run(command, wenv=self.config())
+
+        else:
+            # 否则当作新项目检出完整代码
+            with self.local.cd(self.dir_codebase_project):
+                command = 'pwd && git clone %s .' % (self.project_info['repo_url'])
+                current_app.logger.info('cd %s  command: %s  ', self.dir_codebase_project, command)
+
+                result = self.local.run(command, wenv=self.config())
+
+        # copy to a local version
+        self.release_version = '%s_%s_%s' % (
+            self.project_name, self.task_id, time.strftime('%Y%m%d_%H%M%S', time.localtime(time.time())))
+        with self.local.cd(self.dir_codebase):
+            command = 'cp -rf %s %s' % (self.dir_codebase_project, self.release_version)
+            current_app.logger.info('cd %s  command: %s  ', self.dir_codebase_project, command)
+
+            result = self.local.run(command, wenv=self.config())
+
+        # 更新到指定 commit_id
+        with self.local.cd(self.dir_codebase + self.release_version):
+            command = 'git reset -q --hard %s' % (self.taskMdl.get('commit_id'))
+            result = self.local.run(command, wenv=self.config())
+
+
+            # SocketHandler.send_to_all({
+            #     'type': 'user',
+            #     'id': 33,
+            #     'host': env.host_string,
+            #     'command': command,
+            #     'message': result.stdout,
+            # })
+
+            # 用户自定义命令
+            # command = self.project_info['deploy']
+            # current_app.logger.info(command)
+            # with self.local.cd(self.dir_codebase):
+            #     result = self.local.run(command)
+
+        pass
+
+    def post_deploy(self):
+
+        '''
+        3.检出代码后要做的任务
+        - 用户自定义操作命令
+        - 代码编译
+        - 清除日志文件及无用文件
+        -
+        - 压缩打包
+        - 传送到版本库 release
+        :return:
+        '''
+        self.stage = self.stage_post_deploy
+        self.sequence = 3
+
+        # 用户自定义命令
+        command = self.project_info['post_deploy']
+        current_app.logger.info(command)
+        with self.local.cd(self.dir_codebase + self.release_version):
+            result = self.local.run(command, wenv=self.config())
+
+        # 压缩打包
+        self.release_version_tar = '%s.tgz' % (self.release_version)
+        with self.local.cd(self.dir_codebase):
+            command = 'tar zcvf %s %s' % (self.release_version_tar, self.release_version)
+            result = self.local.run(command, wenv=self.config())
+
+    def prev_release(self, waller):
+        '''
+        4.部署代码到目标机器前做的任务
+        - 检查 webroot 父目录是否存在
+        :return:
+        '''
+        self.stage = self.stage_prev_release
+        self.sequence = 4
+
+        # 检查 target_releases 父目录是否存在
+        command = 'mkdir -p %s' % (self.project_info['target_releases'])
+        result = waller.run(command, wenv=self.config())
+
+        # TODO 检查 webroot 父目录是否存在,是否为软链
+        # command = 'mkdir -p %s' % (self.project_info['target_root'])
+        # result = waller.run(command)
+        # current_app.logger.info('command: %s', dir(result))
+
+
+        # TODO md5
+        # 传送到版本库 release
+        current_app.logger.info('/tmp/walle/codebase/' + self.release_version_tar)
+        result = waller.put('/tmp/walle/codebase/' + self.release_version_tar,
+                            remote=self.project_info['target_releases'])
+        current_app.logger.info('command: %s', dir(result))
+
+        # 解压
+        self.release_untar(waller)
+
+    def release(self, waller):
+        '''
+        5.部署代码到目标机器做的任务
+        - 打包代码 local
+        - scp local => remote
+        - 解压 remote
+        :return:
+        '''
+        self.stage = self.stage_release
+        self.sequence = 5
+
+        with waller.cd(self.project_info['target_releases']):
+            # 1. create a tmp link dir
+            current_link_tmp_dir = '%s/current-tmp-%s' % (self.project_info['target_releases'], self.task_id)
+            command = 'ln -sfn %s/%s %s' % (
+                self.project_info['target_releases'], self.release_version, current_link_tmp_dir)
+            result = waller.run(command, wenv=self.config())
+
+            # 2. make a soft link from release to tmp link
+
+            # 3. move tmp link to webroot
+            current_link_tmp_dir = '%s/current-tmp-%s' % (self.project_info['target_releases'], self.task_id)
+            command = 'mv -fT %s %s' % (current_link_tmp_dir, self.project_info['target_root'])
+            result = waller.run(command, wenv=self.config())
+
+    def release_untar(self, waller):
+        '''
+        解压版本包
+        :return:
+        '''
+        with waller.cd(self.project_info['target_releases']):
+            command = 'tar zxf %s' % (self.release_version_tar)
+            result = waller.run(command, wenv=self.config())
+
+    def post_release(self, waller):
+        '''
+        6.部署代码到目标机器后要做的任务
+        - 切换软链
+        - 重启 nginx
+        :return:
+        '''
+        self.stage = self.stage_post_release
+        self.sequence = 6
+
+        self.post_release_service(waller)
+
+    def post_release_service(self, waller):
+        '''
+        代码部署完成后,服务启动工作,如: nginx重启
+        :param connection:
+        :return:
+        '''
+
+        with waller.cd(self.project_info['target_root']):
+            command = 'sudo service nginx restart'
+            result = waller.run(command, wenv=self.config())
+
+    def list_tag(self):
+        with self.local.cd(self.dir_codebase_project):
+            command = 'git tag -l'
+            current_app.logger.info('cd %s  command: %s  ', self.dir_codebase_project, command)
+            result = self.local.run(command, wenv=self.config())
+            current_app.logger.info(dir(result))
+            return result
+
+        return None
+
+    def list_branch(self):
+        with self.local.cd(self.dir_codebase_project):
+            command = 'git pull'
+            # result = self.local.run(command, wenv=self.config())
+
+            current_app.logger.info(self.dir_codebase_project)
+
+            command = 'git branch -r'
+            result = self.local.run(command, wenv=self.config())
+
+            # TODO 三种可能: false, error, success
+
+            branches = result.stdout.strip().split('\n')
+            # 去除 origin/HEAD -> 当前指向
+            # 去除远端前缀
+            branches = [branch.strip().lstrip('origin/') for branch in branches if not branch.startswith('origin/HEAD')]
+            return branches
+
+        return None
+
+    def list_commit(self, branch):
+        with self.local.cd(self.dir_codebase_project):
+            command = 'git checkout %s && git pull' % (branch)
+            result = self.local.run(command, wenv=self.config())
+
+            # TODO 10是需要前端传的
+            command = 'git log -10 --pretty="%h #_# %an #_# %s"'
+            result = self.local.run(command, wenv=self.config())
+            commit_list = result.stdout.strip().split('\n')
+            commits = []
+            for commit in commit_list:
+                commit_dict = commit.split(' #_# ')
+                commits.append({
+                    'id': commit_dict[0],
+                    'name': commit_dict[1],
+                    'message': commit_dict[2],
+                })
+            return commits
+
+        return None
+
+    def walle_deploy(self):
+
+
+        self.prev_deploy()
+        self.deploy()
+        self.post_deploy()
+
+        server = '172.16.0.231'
+        try:
+            self.connections[server] = Waller(host=server, user=self.project_info['target_user'])
+            self.prev_release(self.connections[server])
+            self.release(self.connections[server])
+            self.post_release(self.connections[server])
+        except Exception, e:
+            current_app.logger.exception(e)
+            self.errors[server] = e.message
+
+        # for server_info in self.servers:
+        #     server = server_info.host
+        #     try:
+        #         self.connections[server] = Waller(host=server, user=self.project_info['target_user'])
+        #         self.prev_release(self.connections[server])
+        #         self.release(self.connections[server])
+        #         self.post_release(self.connections[server])
+        #     except Exception, e:
+        #         self.errors[server] = e.message
+
+        return {'success': self.success, 'errors': self.errors}

+ 118 - 0
walle/service/emails.py

@@ -0,0 +1,118 @@
+""" This file contains email sending functions for Flask-User.
+    It uses Jinja2 to render email subject and email message. It uses Flask-Mail to send email.
+
+    :copyright: (c) 2013 by Ling Thio
+    :author: Ling Thio (ling.thio@gmail.com)
+    :license: Simplified BSD License, see LICENSE.txt for more details."""
+
+import smtplib
+import socket
+
+from flask import current_app, render_template
+from flask import url_for
+
+from walle.service.extensions import mail
+from flask_mail import Message
+from walle.service import tokens
+
+
+def _render_email(filename, **kwargs):
+    # Render subject
+    subject = render_template(filename + '_subject.txt', **kwargs)
+    # Make sure that subject lines do not contain newlines
+    subject = subject.replace('\n', ' ')
+    subject = subject.replace('\r', ' ')
+    # Render HTML message
+    html_message = render_template(filename + '_message.html', **kwargs)
+    # Render text message
+    text_message = render_template(filename + '_message.txt', **kwargs)
+
+    return (subject, html_message, text_message)
+
+
+def send_email(recipient, subject, html_message, text_message):
+    """ Send email from default sender to 'recipient' """
+    # Make sure that Flask-Mail has been initialized
+    mail_engine = mail
+    if not mail_engine:
+        return 'Flask-Mail has not been initialized. Initialize Flask-Mail or disable USER_SEND_PASSWORD_CHANGED_EMAIL, USER_SEND_REGISTERED_EMAIL and USER_SEND_USERNAME_CHANGED_EMAIL'
+
+    try:
+
+        # Construct Flash-Mail message
+        message = Message(subject,
+                          recipients=[recipient],
+                          html=html_message,
+                          body=text_message)
+        return mail.send(message)
+
+    # Print helpful error messages on exceptions
+    except (socket.gaierror, socket.error) as e:
+        return 'SMTP Connection error: Check your MAIL_SERVER and MAIL_PORT settings.'
+    except smtplib.SMTPAuthenticationError:
+        return 'SMTP Authentication error: Check your MAIL_USERNAME and MAIL_PASSWORD settings.'
+
+
+def get_primary_user_email(user):
+    user_manager = current_app.user_manager
+    db_adapter = user_manager.db_adapter
+    if db_adapter.UserEmailClass:
+        user_email = db_adapter.find_first_object(db_adapter.UserEmailClass,
+                                                  user_id=int(user.get_id()),
+                                                  is_primary=True)
+        return user_email
+    else:
+        return user
+
+
+def send_confirm_email_email(user, user_email, confirm_email_link):
+    # Verify certain conditions
+    user_manager = current_app.user_manager
+    if not user_manager.enable_email: return
+    if not user_manager.send_registered_email and not user_manager.enable_confirm_email: return
+
+    # Retrieve email address from User or UserEmail object
+    email = user_email.email if user_email else user.email
+    assert (email)
+
+    # Render subject, html message and text message
+    subject, html_message, text_message = _render_email(
+            user_manager.confirm_email_email_template,
+            user=user,
+            app_name=user_manager.app_name,
+            confirm_email_link=confirm_email_link)
+
+    # Send email message using Flask-Mail
+    user_manager.send_email_function(email, subject, html_message, text_message)
+
+
+def send_registered_email(user, confirm_email_link):  # pragma: no cover
+    # Verify certain conditions
+    # user_manager =  current_app.user_manager
+    # if not user_manager.enable_email: return
+    # if not user_manager.send_registered_email: return
+
+    # Retrieve email address from User or UserEmail object
+    email = user.email
+    assert (email)
+
+    # Render subject, html message and text message
+    subject, html_message, text_message = _render_email(
+            'emails/registered',
+            user=user,
+            app_name='walle',
+            confirm_email_link=confirm_email_link)
+
+    # Send email message using Flask-Mail
+    return send_email(email, subject, html_message, text_message)
+
+
+def public_send_registered_email(user, require_email_confirmation=True):
+    # Send 'confirm_email' or 'registered' email
+    # Generate confirm email link
+    token_manager = tokens.TokenManager()
+    token = token_manager.generate_token(int(user.id))
+    confirm_email_link = url_for('deploy.confirm_mail', token=token, _external=True)
+
+    # Send email
+    return send_registered_email(user, confirm_email_link)

+ 40 - 0
walle/service/error.py

@@ -0,0 +1,40 @@
+# -*- coding: utf-8 -*-
+"""
+    walle-web
+
+    :copyright: © 2015-2019 walle-web.io
+    :created time: 2018-11-17 21:42:23
+    :author: wushuiyong@walle-web.io
+"""
+from flask import current_app, jsonify
+from walle.service.code import Code
+
+
+class WalleError(Exception):
+
+    # 默认的返回码
+    code = Code.unlogin
+
+    message = None
+
+    def __init__(self, code, message=None):
+        Exception.__init__(self)
+
+        current_app.logger.info('======= CustomError ======')
+        if code is not None:
+            self.code = code
+        if message is not None:
+            self.message = message
+
+    def render_error(self):
+        if not Code.code_msg.has_key(self.code):
+            current_app.logger.error('unkown code %s' % (self.code))
+
+        if Code.code_msg.has_key(self.code):
+            self.message = Code.code_msg[self.code]
+
+        return jsonify({
+            'code': self.code,
+            'message': self.message,
+            'data': None,
+        })

+ 20 - 0
walle/service/extensions.py

@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+"""Extensions module. Each extension is initialized in the app factory located in app.py."""
+from flask_bcrypt import Bcrypt
+from flask_login import LoginManager
+from flask_migrate import Migrate
+from flask_sqlalchemy import SQLAlchemy
+from flask_wtf.csrf import CSRFProtect
+from flask_mail import Mail
+from walle.service.rbac.role import Permission
+
+
+bcrypt = Bcrypt()
+csrf_protect = CSRFProtect()
+db = SQLAlchemy()
+migrate = Migrate()
+
+login_manager = LoginManager()
+mail = Mail()
+
+permission = Permission()

+ 8 - 0
walle/service/rbac/__init__.py

@@ -0,0 +1,8 @@
+# -*- coding: utf-8 -*-
+"""
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2017-06-11 15:39:17
+    :author: wushuiyong@walle-web.io
+"""

+ 43 - 0
walle/service/rbac/access.py

@@ -0,0 +1,43 @@
+# -*- coding: utf-8 -*-
+"""
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2017-06-11 15:40:38
+    :author: wushuiyong@walle-web.io
+"""
+import logging
+from functools import wraps
+
+from flask import current_app
+from flask_login import current_user
+
+class Access:
+
+    def __init__(self):
+        pass
+
+    @staticmethod
+    def is_login():
+        # return True
+        current_app.logger.info(current_user.is_authenticated)
+        return current_user.is_authenticated
+
+    @staticmethod
+    def is_allow(action, controller, module=None):
+        # return True
+        current_resource = Access.resource(action, controller, module)
+        # _role_delete
+        return True
+        # if current_user.is_authenticated:
+        #     user_has_resource = current_user.fetch_access_list_by_role_id(current_user.role_id)
+        # else:
+        #     user_has_resource = []
+        # logging.error(current_resource)
+        # logging.error(user_has_resource)
+        # return current_resource in user_has_resource
+
+    @staticmethod
+    def resource(action, controller, module=None):
+        return "{}_{}_{}".format(module, controller, str(action))
+

+ 27 - 0
walle/service/rbac/passport.py

@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+"""
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2017-06-14 15:53:46
+    :author: wushuiyong@walle-web.io
+"""
+
+import logging
+from walle.service.extensions import login_manager
+from walle.model.user import UserModel
+from walle.model.user import RoleModel
+from walle.model.user import MenuModel
+
+
+@login_manager.user_loader
+def load_user(user_id):
+    logging.error(user_id)
+    user = UserModel.query.get(user_id)
+    role = RoleModel().item(user.role_id)
+    access = MenuModel().fetch_access_list_by_role_id(user.role_id)
+    logging.error(access)
+    # logging.error(RoleModel.query.get(user.role_id).access_ids)
+    # logging.error(role['access_ids'].split(','))
+    # logging.error(UserModel.query.get(user_id))
+    return UserModel.query.get(user_id)

+ 129 - 0
walle/service/rbac/role.py

@@ -0,0 +1,129 @@
+# -*- coding: utf-8 -*-
+"""
+    walle-web
+
+    :copyright: © 2015-2017 walle-web.io
+    :created time: 2018-11-04 22:08:28
+    :author: wushuiyong@walle-web.io
+"""
+from flask import current_app, session
+from flask_login import login_required, current_user
+from functools import wraps
+from walle.service.code import Code
+from walle.service.error import WalleError
+
+GUEST = 'GUEST'
+REPORT = 'REPORT'
+DEVELOPER = 'DEVELOPER'
+MASTER = 'MASTER'
+OWNER = 'OWNER'
+SUPER = 'SUPER'
+
+ACCESS_ROLE = {
+    '10': GUEST,
+    '20': REPORT,
+    '30': DEVELOPER,
+    '40': MASTER,
+    '50': OWNER,
+    '60': SUPER,
+}
+
+ROLE_ACCESS = {
+    'GUEST': '10',
+    'REPORT': '20',
+    'DEVELOPER': '30',
+    'MASTER': '40',
+    'OWNER': '50',
+    'SUPER': '60',
+}
+
+class Permission():
+
+    app = None
+
+    def __init__(self, app=None):
+        if app:
+            self.init_app(app)
+
+    def init_app(self, app):
+        self.app = app
+
+    def gte_develop_or_uid(self, func):
+        @wraps(func)
+        @login_required
+        def decorator(*args, **kwargs):
+            current_app.logger.info('============== gte_develop_or_uid.decorator ======')
+            if self.is_gte_develop_or_uid(current_user.id):
+                current_app.logger.info('============== gte_develop_or_uid.if ======')
+                return func(*args, **kwargs)
+
+            raise WalleError(Code.not_allow)
+
+        return decorator
+
+    def is_gte_develop_or_uid(self, uid=None):
+        if uid is None:
+            uid = current_user.id
+
+        if self.enable_uid(uid) or self.enable_role(DEVELOPER):
+            return True
+
+        return False
+
+    @staticmethod
+    def list_enable(self, list, access_level):
+        current_role = OWNER
+        access_level = {
+            'create': OWNER,
+            'update': MASTER,
+            'delete': MASTER,
+            'online': DEVELOPER,
+            'audit': MASTER,
+            'block': DEVELOPER,
+        }
+        # 1 uid == current_uid && access_level >= current_role
+        #       all true
+        # uid, project_id, space_id
+
+        return {
+            'enable_create': OWNER,
+            'enable_update': MASTER,
+            'enable_delete': MASTER,
+            'enable_online': DEVELOPER,
+            'enable_audit': MASTER,
+            'enable_block': DEVELOPER,
+        }
+        pass
+
+    # @classmethod
+    def enable_uid(self, uid):
+        '''
+        当前登录用户 == 数据用户
+        :param uid:
+        :return:
+        '''
+        # TODO
+        # current_app.logger.info(current_user.id)
+        # current_app.logger.info(current_user.is_active())
+        current_app.logger.info(dir(current_user))
+        current_app.logger.info(uid)
+        return current_user.id == uid
+
+    # @classmethod
+    def enable_role(self, role):
+        '''
+        当前角色 >= 数据项角色
+        :param role:
+        :return:
+        '''
+        # TODO about project/task
+        current_role = session['space_info']['role']
+        return self.compare_role(current_role, role)
+
+    # @classmethod
+    def compare_role(self, role_high, role_low):
+        if role_high not in ROLE_ACCESS or role_low not in ROLE_ACCESS:
+            # TODO 也可以抛出
+            return False
+
+        return ROLE_ACCESS[role_high] > ROLE_ACCESS[role_low]

+ 85 - 0
walle/service/tokens.py

@@ -0,0 +1,85 @@
+# -*- coding: utf-8 -*-
+""" This file contains functions to generate and verify tokens for Flask-User.
+    Tokens contain an encoded user ID and a signature. The signature is managed by the itsdangerous module.
+
+    :copyright: (c) 2013 by Ling Thio
+    :author: Ling Thio (ling.thio@gmail.com)
+    :license: Simplified BSD License, see LICENSE.txt for more details."""
+
+import base64
+
+from Crypto.Cipher import AES
+from itsdangerous import BadSignature, SignatureExpired, TimestampSigner
+
+
+class TokenManager(object):
+    def __init__(self):
+        """ Create a cypher to encrypt IDs and a signer to sign tokens."""
+        # Create cypher to encrypt IDs
+        # and ensure >=16 characters
+        # secret = app.config.get('SECRET_KEY')
+        secret = 'SECRET_KEY'
+        precursor = b'0123456789abcdef'
+        if isinstance(secret, bytes):
+            key = secret + precursor
+        else:
+            key = secret.encode("utf-8") + precursor
+        self.cipher = AES.new(key[0:16], AES.MODE_ECB)
+
+        # Create signer to sign tokens
+        self.signer = TimestampSigner(secret)
+
+    def encrypt_id(self, id):
+        """ Encrypts integer ID to url-safe base64 string."""
+        # 16 byte integer
+        str1 = '%016d' % id
+        # encrypted data
+        str2 = self.cipher.encrypt(str1.encode())
+        # URL safe base64 string with '=='
+        str3 = base64.urlsafe_b64encode(str2)
+        # return base64 string without '=='
+        return str3[0:-2]
+
+    def decrypt_id(self, encrypted_id):
+        """ Decrypts url-safe base64 string to integer ID"""
+        # Convert strings and unicode strings to bytes if needed
+        if hasattr(encrypted_id, 'encode'):
+            encrypted_id = encrypted_id.encode('ascii', 'ignore')
+
+        try:
+            str3 = encrypted_id + b'=='  # --> base64 string with '=='
+            # print('str3=', str3)
+            str2 = base64.urlsafe_b64decode(str3)  # --> encrypted data
+            # print('str2=', str2)
+            str1 = self.cipher.decrypt(str2)  # --> 16 byte integer string
+            # print('str1=', str1)
+            return int(str1)  # --> integer id
+        except Exception as e:  # pragma: no cover
+            print('!!!Exception in decrypt_id!!!:', e)
+            return 0
+
+    def generate_token(self, id):
+        """ Return token with id, timestamp and signature"""
+        # In Python3 we must make sure that bytes are converted to strings.
+        # Hence the addition of '.decode()'
+        return self.signer.sign(self.encrypt_id(id)).decode()
+
+    def verify_token(self, token, expiration_in_seconds):
+        """ Verify token and return (is_valid, has_expired, id).
+            Returns (True, False, id) on success.
+            Returns (False, True, None) on expired tokens.
+            Returns (False, False, None) on invalid tokens."""
+        try:
+            data = self.signer.unsign(token, max_age=expiration_in_seconds)
+            is_valid = True
+            has_expired = False
+            id = self.decrypt_id(data)
+        except SignatureExpired:
+            is_valid = False
+            has_expired = True
+            id = None
+        except BadSignature:
+            is_valid = False
+            has_expired = False
+            id = None
+        return (is_valid, has_expired, id)

+ 38 - 0
walle/service/utils.py

@@ -0,0 +1,38 @@
+# -*- coding: utf-8 -*-
+"""Helper utilities and decorators."""
+import sys
+import time
+from datetime import datetime
+
+from flask import flash
+
+
+def flash_errors(form, category='warning'):
+    """Flash all errors for a form."""
+    for field, errors in form.errors.items():
+        for error in errors:
+            flash('{0} - {1}'.format(getattr(form, field).label.text, error), category)
+
+
+def date_str_to_obj(ymd):
+    return time.strptime(ymd, '%Y-%m-%d')
+
+
+def datetime_str_to_obj(ymd_his):
+    return datetime.strptime(ymd_his, "%Y-%m-%d %H:%i:%s")
+
+
+PY2 = int(sys.version[0]) == 2
+
+if PY2:
+    text_type = unicode  # noqa
+    binary_type = str
+    string_types = (str, unicode)  # noqa
+    unicode = unicode  # noqa
+    basestring = basestring  # noqa
+else:
+    text_type = str
+    binary_type = bytes
+    string_types = (str,)
+    unicode = str
+    basestring = (str, bytes)

+ 159 - 0
walle/service/waller.py

@@ -0,0 +1,159 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# @Author: wushuiyong
+# @Created Time : 日  1/ 1 23:43:12 2017
+# @Description:
+
+from fabric2 import Connection
+from flask import current_app
+from walle.model.deploy import TaskRecordModel
+
+
+
+
+class Waller(Connection):
+    connections, success, errors = {}, {}, {}
+    release_version_tar, release_version = None, None
+
+    def run(self, command, wenv=None, sudo=False, **kwargs):
+        '''
+        # TODO
+        pty=True/False是直接影响到输出.False较适合在获取文本,True更适合websocket
+
+        :param command:
+        :param wenv:
+        :param sudo:
+        :param kwargs:
+        :return:
+        '''
+        try:
+            message = 'task_id=%s, host:%s command:%s' % (
+                wenv['task_id'], self.host, command
+            )
+            current_app.logger.info(message)
+            if sudo:
+                result = super(Waller, self).sudo(command, pty=False, **kwargs)
+            else:
+                result = super(Waller, self).run(command, pty=False, **kwargs)
+
+            message = 'task_id=%s, host:%s command:%s status:%s, success:%s, error:%s' % (
+                wenv['task_id'], self.host, command, result.exited, result.stdout.strip(), result.stderr.strip()
+            )
+
+            # TODO
+            if wenv.has_key('websocket') and wenv['websocket']:
+
+                ws_dict = {
+                    'host': self.host,
+                    'cmd': command,
+                    'status': result.exited,
+                    'stage': wenv['stage'],
+                    'sequence': wenv['sequence'],
+                    'success': result.stdout.strip(),
+                    'error': result.stderr.strip(),
+                }
+                wenv['websocket'].send_updates(ws_dict)
+            TaskRecordModel().save_record(stage=wenv['stage'], sequence=wenv['sequence'], user_id=wenv['user_id'],
+                                          task_id=wenv['task_id'], status=result.exited, host=self.host, user=self.user,
+                                          command=result.command,success=result.stdout.strip(), error=result.stderr.strip())
+            current_app.logger.info(message)
+            return result
+
+        except Exception, e:
+            #current_app.logger.exception(e)
+            #return None
+            # TODO 貌似可能的异常有很多种,需要分层才能完美解决 something wrong without e.result
+            TaskRecordModel().save_record(stage=wenv['stage'], sequence=wenv['sequence'], user_id=wenv['user_id'],
+                                          task_id=wenv['task_id'], status=1, host=self.host, user=self.user,
+                                          command=command, success='', error='e.result')
+            if hasattr(e, 'resean') and hasattr(e, 'result'):
+                message = 'task_id=%s, host:%s command:%s, status=1, reason:%s, result:%s' % (
+                    wenv['task_id'], self.host, command, e.reason, e.result
+                )
+            else:
+                message = 'task_id=%s, host:%s command:%s, status=1, message:%s' % (
+                    wenv['task_id'], self.host, command, e.message
+                )
+
+            # TODO
+            if wenv.has_key('websocket') and wenv['websocket']:
+
+                ws_dict = {
+                    'host': self.host,
+                    'cmd': command,
+                    'status': 1,
+                    'stage': wenv['stage'],
+                    'sequence': wenv['sequence'],
+                    'success': '',
+                    'error': e.message,
+                }
+                wenv['websocket'].send_updates(ws_dict)
+            # current_app.logger.error(message)
+
+            return False
+
+    def sudo(self, command, wenv=None, **kwargs):
+        return self.run(command, wenv=wenv, sudo=True, **kwargs)
+
+    def get(self, remote, local=None, wenv=None):
+        return self.sync(wtype='get', remote=remote, local=local, wenv=wenv)
+
+    def put(self, local, remote=None, wenv=None, *args, **kwargs):
+        return self.sync(wtype='put', local=local, remote=remote, wenv=wenv, *args, **kwargs)
+
+    def sync(self, wtype, remote=None, local=None, wenv=None):
+        try:
+            if wtype == 'put':
+                result = super(Waller, self).put(local=local, remote=remote)
+                command = 'put: scp %s %s@%s:%s' % (result.local, self.user, self.host, result.remote)
+                current_app.logger.info('put: local %s, remote %s', local, remote)
+
+            else:
+                result = super(Waller, self).get(remote=remote, local=local)
+                command = 'get: scp %s@%s:%s %s' % (self.user, self.host, result.remote, result.local)
+                current_app.logger.info('get: local %s, remote %s', local, remote)
+                current_app.logger.info('get: orig_local %s, local %s', result.orig_local, result.local)
+
+            current_app.logger.info('put: %s, %s', result, dir(result))
+            # TODO 可能会有非22端口的问题
+            TaskRecordModel().save_record(stage=wenv['stage'], sequence=wenv['sequence'], user_id=wenv['user_id'],
+                                          task_id=wenv['task_id'], status=0, host=self.host, user=self.user,
+                                          command=command, )
+            message = 'task_id=%d, host:%s command:%s status:0, success:, error:' % (
+            wenv['task_id'], self.host, command)
+            current_app.logger.info(message)
+
+            # TODO
+            if wenv.has_key('websocket') and wenv['websocket']:
+
+                ws_dict = {
+                    'host': self.host,
+                    'cmd': command,
+                    'status': 1,
+                    'stage': wenv['stage'],
+                    'sequence': wenv['sequence'],
+                    'success': '',
+                    'error': result.stderr.strip(),
+                }
+                wenv['websocket'].send_updates(ws_dict)
+
+            return result
+        except Exception, e:
+            # TODO 收尾下
+            current_app.logger.info('put: %s, %s', e, dir(e))
+
+
+            # TODO
+            if wenv.has_key('websocket') and wenv['websocket']:
+
+                # TODO command
+                ws_dict = {
+                    'host': self.host,
+                    'cmd': 'command',
+                    'status': 1,
+                    'stage': wenv['stage'],
+                    'sequence': wenv['sequence'],
+                    'success': '',
+                    'error': e.message,
+                }
+                wenv['websocket'].send_updates(ws_dict)

+ 72 - 0
walle/service/websocket.py

@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+"""
+    walle-web
+
+    :copyright: © 2015-2019 walle-web.io
+    :created time: 2018-09-06 20:20:33
+    :author: wushuiyong@walle-web.io
+"""
+import anyjson as json
+from tornado.websocket import WebSocketHandler
+from flask import current_app
+from flask_login import current_user
+from walle.service.deployer import Deployer
+
+class WSHandler(WebSocketHandler):
+    waiters = set()
+
+    app = None
+
+    def init_app(self, app):
+        self.app = app
+
+    def check_origin(self, origin):
+        return True
+
+    def open(self):
+        # TODO
+        # from walle.model.user import UserModel
+        # from flask_login import login_user
+        # user = UserModel.query.filter_by(email='wushuiyong-owner@walle-web.io').first()
+        # login_user(user)
+        from flask import session
+
+        ctx = current_app.app_context()
+        ctx.push()
+        current_app.logger.info(session['space_id'])
+
+        WSHandler.waiters.add(self)
+
+        print 'new connection'
+        self.write_message(json.dumps(dict(output="Hello World")))
+
+    def on_message(self, incoming):
+        print 'message received %s' % incoming
+
+        text = json.loads(incoming).get('text', None)
+        task_id = text if text else 'Sorry could you repeat?'
+
+
+        wi = Deployer(task_id, websocket=self)
+        current_app.logger.info(current_user.id)
+        ret = wi.walle_deploy()
+
+        response = json.dumps(dict(output='receive: {0}'.format(task_id)))
+        self.write_message(response)
+
+    def on_close(self):
+        print 'connection closed'
+
+
+    @classmethod
+    def send_updates(cls, incoming):
+        response = json.dumps(incoming)
+        current_app.logger.info("sending %s to %d waiters", str(incoming), len(cls.waiters))
+        current_app.logger.info(cls.waiters)
+        for waiter in cls.waiters:
+            try:
+                waiter.write_message(response)
+            except Exception, e:
+                current_app.logger.exception(e)
+
+

+ 47 - 0
walle/templates/websocket.html

@@ -0,0 +1,47 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="utf-8">
+</head>
+<p>Flask Tornado</p>
+<p id="log"></p>
+<input id="say" type="text">
+<button id="send" type="button">Send!</button>
+<body>
+<script src="http://code.jquery.com/jquery-1.8.2.min.js"></script>
+<script>
+    $(document).ready(function () {
+
+        if ("WebSocket" in window) {
+            ws = new WebSocket("ws://" + document.domain + "/websocket/console");
+            ws.onmessage = function (msg) {
+                var message = JSON.parse(msg.data);
+                $("p#log").html($("p#log").html() + "<p>" + msg.data + "</p>");
+            };
+
+            // Bind send button to websocket
+            var $send = $("button#send")
+                    , $say = $("input#say");
+
+            $send.live("click", function () {
+                ws.send(JSON.stringify({'text': $say.val()}));
+            });
+
+            // Cleanly close websocket when unload window
+            window.onbeforeunload = function () {
+                ws.onclose = function () {
+                }; // disable onclose handler first
+                ws.close()
+            };
+        }
+        ;
+    });
+
+    $(document).keyup(function (event) {
+        if (event.keyCode == 13) {
+            $("#send").trigger("click");
+        }
+    });
+</script>
+</body>
+</html>

+ 16 - 0
waller.py

@@ -0,0 +1,16 @@
+# -*- coding: utf-8 -*-
+"""Create an application instance."""
+import sys
+
+from flask.helpers import get_debug_flag
+from walle.app import create_app
+from walle.config.settings_dev import DevConfig
+from walle.config.settings_test import TestConfig
+from walle.config.settings_prod import ProdConfig
+
+CONFIG = DevConfig if get_debug_flag(default=True) else ProdConfig
+
+if len(sys.argv) > 2 and sys.argv[2] == 'test':
+    CONFIG = TestConfig
+
+app = create_app(CONFIG)