├── .gitignore ├── LICENSE ├── README.md ├── apis ├── __init__.py ├── app.py ├── auth │ ├── __init__.py │ ├── api │ │ ├── __init__.py │ │ ├── accounts_self.py │ │ ├── accounts_wxapp.py │ │ ├── oauth_token.py │ │ ├── oauth_token_code.py │ │ ├── oauth_token_refresh.py │ │ ├── self_password.py │ │ └── self_password_reset.py │ ├── routes.py │ ├── schemas.py │ └── validators.py ├── custom_errors.py ├── exception.py ├── helpers.py ├── models │ ├── __init__.py │ ├── choice.py │ ├── model.py │ ├── oauth.py │ └── test.py ├── settings.py ├── v1 │ ├── __init__.py │ ├── api │ │ ├── __init__.py │ │ ├── qc_cos_config.py │ │ ├── self_testings.py │ │ ├── self_tests.py │ │ ├── self_tests_id.py │ │ ├── self_tests_id_publish.py │ │ ├── self_tests_id_questions.py │ │ ├── self_tests_test_id_questions_id.py │ │ ├── tests_banner.py │ │ ├── tests_handpick.py │ │ ├── tests_id.py │ │ ├── tests_id_answers.py │ │ ├── tests_id_questions.py │ │ ├── tests_id_score.py │ │ └── tests_id_statistics.py │ ├── routes.py │ ├── schemas.py │ └── validators.py └── verification.py ├── docs ├── auth.yml └── v1.yml ├── manager.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # python 2 | *.py[cod] 3 | *.swp 4 | 5 | .DS_Store 6 | .vscode/ 7 | 8 | # build 9 | .gitconfig 10 | .installed.cfg 11 | .ropeproject/ 12 | ./apis/ropeproject/ 13 | ./__pycache__/ 14 | ./apis/__pycache__/ 15 | apis/static/ 16 | .idea/ 17 | bin/ 18 | parts/ 19 | eggs/ 20 | build/ 21 | dist/ 22 | access.log* 23 | error.log* 24 | newrelic.ini 25 | 26 | wxapp/local_config.js 27 | apis/local_settings.py 28 | 29 | # test 30 | test_example.py 31 | test_settings.py 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Metis 2 | 3 | 测试题小程序后端api接口 4 | -------------------------------------------------------------------------------- /apis/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /apis/app.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from sanic import Sanic 4 | from sanic.response import json 5 | from sanic.exceptions import NotFound as _NotFound, InvalidUsage 6 | from apis.exception import (Unauthorized, NotFound, BadRequest, 7 | Forbidden, RequestTimeout, ServerError) 8 | from apis.custom_errors import (UnprocessableEntity, 9 | ServerError as _ServerError, 10 | Forbidden as _Forbidden) 11 | 12 | 13 | from apis.settings import Config 14 | 15 | 16 | def create_app(register_bp=True, test=False): 17 | app = Sanic(__name__) 18 | if test: 19 | app.config['TESTING'] = True 20 | app.config.from_object(Config) 21 | app.static('/static/', './apis/static') 22 | if register_bp: 23 | register_blueprints(app) 24 | return app 25 | 26 | 27 | def register_blueprints(app): 28 | from apis.auth import bp as auth_bp 29 | from apis.v1 import bp as v1_bp 30 | app.blueprint(auth_bp) 31 | app.blueprint(v1_bp) 32 | 33 | 34 | app = create_app() 35 | 36 | 37 | all_json_errors = [BadRequest, Unauthorized, Forbidden, 38 | NotFound, RequestTimeout, ServerError] 39 | 40 | 41 | @app.exception(*all_json_errors) 42 | def json_error(request, exception): 43 | return json( 44 | { 45 | 'error_code': exception.error_code, 46 | 'message': exception.message, 47 | 'text': exception.text 48 | }, 49 | status=exception.status_code) 50 | 51 | 52 | # swagger validators 需要用到 53 | @app.exception(_NotFound) 54 | def not_found(request, exception): 55 | return json({'error_code': 'not_found', 56 | 'message': exception.args[0]}, 57 | status=exception.status_code, 58 | ) 59 | 60 | 61 | @app.exception(UnprocessableEntity, _Forbidden, _ServerError) 62 | def custom_json_errors(request, exception): 63 | return json( 64 | { 65 | 'error_code': exception.error_code, 66 | 'message': exception.message, 67 | 'errors': exception.errors 68 | }, 69 | status=exception.status_code) 70 | 71 | 72 | @app.exception(InvalidUsage) 73 | def method_not_allow(request, exception): 74 | return json({'error_code': 'method_not_allow', 75 | 'message': exception.args[0]}, 76 | status=exception.status_code, 77 | ) -------------------------------------------------------------------------------- /apis/auth/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | 4 | from sanic import Blueprint 5 | 6 | from apis.verification import verify_request, current_account 7 | 8 | from .routes import routes 9 | from .validators import security 10 | 11 | 12 | @security.scopes_loader 13 | def current_scopes(request): 14 | is_validate, token = verify_request(request) 15 | if is_validate and token: 16 | if isinstance(token, list): 17 | return token 18 | current_account.id = token.sub 19 | return token.scopes 20 | return [] 21 | 22 | bp = Blueprint('auth', url_prefix='/auth') # 需要加 url_prefix 23 | 24 | for route in routes: 25 | route.pop('endpoint', None) 26 | bp.add_route(route.pop('resource'), *route.pop('urls'), **route) 27 | -------------------------------------------------------------------------------- /apis/auth/api/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | 4 | import inspect 5 | 6 | from sanic.views import HTTPMethodView 7 | 8 | from ..validators import request_validate, response_filter 9 | 10 | before_decorators = [request_validate] 11 | after_decorators = [response_filter] 12 | 13 | methods = ['get', 'put', 'post', 'delete'] 14 | 15 | 16 | def add_before_decorators(model_class): 17 | for name, m in inspect.getmembers(model_class, inspect.isfunction): 18 | if name in methods: 19 | for dec in before_decorators: 20 | m = dec(m) 21 | setattr(model_class, name, m) 22 | 23 | 24 | def add_after_decorators(model_class): 25 | for name, m in inspect.getmembers(model_class, inspect.isfunction): 26 | if name in methods: 27 | for dec in after_decorators: 28 | m = dec(m) 29 | setattr(model_class, name, m) 30 | 31 | 32 | class APIMetaclass(type): 33 | """ 34 | Metaclass of the Model. 35 | """ 36 | def __init__(cls, name, bases, attrs): 37 | super(APIMetaclass, cls).__init__(name, bases, attrs) 38 | add_before_decorators(cls) 39 | add_after_decorators(cls) 40 | 41 | 42 | class Resource(HTTPMethodView, metaclass=APIMetaclass): 43 | 44 | 45 | pass -------------------------------------------------------------------------------- /apis/auth/api/accounts_self.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from apis.verification import current_account 4 | from apis.exception import NotFound 5 | from apis.models.oauth import Account 6 | from . import Resource 7 | 8 | 9 | class AccountsSelf(Resource): 10 | 11 | async def get(self, request): 12 | if not current_account.id: 13 | raise NotFound('account_not_found') 14 | account = Account.objects(id=current_account.id).first() 15 | if not account: 16 | raise NotFound('account_not_found') 17 | return account, 200 18 | -------------------------------------------------------------------------------- /apis/auth/api/accounts_wxapp.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from sanic.response import text, json 4 | 5 | from apis.models import generation_objectid 6 | from apis.models.oauth import Account 7 | from apis.exception import BadRequest 8 | from apis.verification import get_wxapp_userinfo 9 | 10 | from . import Resource 11 | 12 | 13 | class AccountsWxapp(Resource): 14 | 15 | async def post(self, request): 16 | encrypted_data = request.json.get('username') 17 | iv = request.json.get('password') 18 | code = request.json.get('code') 19 | user_info = get_wxapp_userinfo(encrypted_data, iv, code) 20 | openid = user_info.get('openId') 21 | account = Account.get_by_wxapp(openid=openid) 22 | if account: 23 | raise BadRequest('wxapp_already_registered') 24 | params = { 25 | 'id': generation_objectid(), 26 | 'nickname': user_info['nickName'], 27 | 'avatar': user_info['avatarUrl'], 28 | 'authentications': {'wxapp': openid}, 29 | } 30 | account = Account(**params) 31 | account.save() 32 | return account, 201 33 | -------------------------------------------------------------------------------- /apis/auth/api/oauth_token.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from apis.verification import create_token 4 | from apis.exception import Unauthorized 5 | 6 | from . import Resource 7 | 8 | 9 | class OauthToken(Resource): 10 | 11 | async def post(self, request): 12 | is_validate, token = create_token(request) 13 | if not is_validate: 14 | raise Unauthorized('unauthorized', 'Invalid token', '用户未注册') 15 | return token, 201 16 | -------------------------------------------------------------------------------- /apis/auth/api/oauth_token_code.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from sanic.response import text 4 | 5 | from . import Resource 6 | from .. import schemas 7 | 8 | 9 | class OauthTokenCode(Resource): 10 | 11 | async def post(self, request): 12 | print(request.json) 13 | print(request.headers) 14 | 15 | return {'account_id': 'something', 'access_token': 'something', 'token_type': 'jwt'}, 200, None -------------------------------------------------------------------------------- /apis/auth/api/oauth_token_refresh.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from sanic.response import text 4 | 5 | from . import Resource 6 | from .. import schemas 7 | 8 | 9 | class OauthTokenRefresh(Resource): 10 | 11 | async def post(self, request): 12 | print(request.json) 13 | print(request.headers) 14 | 15 | return {'account_id': 'something', 'access_token': 'something', 'refresh_token': 'something', 'token_type': 'Bearer', 'expires_in': 9573, 'scopes': 'something'}, 200, None -------------------------------------------------------------------------------- /apis/auth/api/self_password.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from sanic.response import text 4 | 5 | from . import Resource 6 | from .. import schemas 7 | 8 | 9 | class SelfPassword(Resource): 10 | 11 | async def post(self, request): 12 | print(request.json) 13 | return {'hahahha': 1234}, 201 14 | return {}, 201, None 15 | 16 | async def put(self, request): 17 | print(request.json) 18 | print(request.headers) 19 | return {'hahahha': 1234} 20 | 21 | return {}, 200, None -------------------------------------------------------------------------------- /apis/auth/api/self_password_reset.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from sanic.response import text 4 | 5 | from . import Resource 6 | from .. import schemas 7 | 8 | 9 | class SelfPasswordReset(Resource): 10 | 11 | async def post(self, request): 12 | print(request.json) 13 | print(request.headers) 14 | 15 | return {}, 200, None -------------------------------------------------------------------------------- /apis/auth/routes.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | ### 4 | ### DO NOT CHANGE THIS FILE 5 | ### 6 | ### The code is auto generated, your change will be overwritten by 7 | ### code generating. 8 | ### 9 | from __future__ import absolute_import 10 | 11 | from .api.oauth_token_code import OauthTokenCode 12 | from .api.accounts_self import AccountsSelf 13 | from .api.oauth_token import OauthToken 14 | from .api.self_password_reset import SelfPasswordReset 15 | from .api.self_password import SelfPassword 16 | from .api.oauth_token_refresh import OauthTokenRefresh 17 | from .api.accounts_wxapp import AccountsWxapp 18 | 19 | 20 | routes = [ 21 | dict(resource=OauthTokenCode.as_view(), urls=['/oauth/token/code'], endpoint='oauth_token_code'), 22 | dict(resource=AccountsSelf.as_view(), urls=['/accounts/self'], endpoint='accounts_self'), 23 | dict(resource=OauthToken.as_view(), urls=['/oauth/token'], endpoint='oauth_token'), 24 | dict(resource=SelfPasswordReset.as_view(), urls=['/self/password/reset'], endpoint='self_password_reset'), 25 | dict(resource=SelfPassword.as_view(), urls=['/self/password'], endpoint='self_password'), 26 | dict(resource=OauthTokenRefresh.as_view(), urls=['/oauth/token/refresh'], endpoint='oauth_token_refresh'), 27 | dict(resource=AccountsWxapp.as_view(), urls=['/accounts/wxapp'], endpoint='accounts_wxapp'), 28 | ] -------------------------------------------------------------------------------- /apis/auth/schemas.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # TODO: datetime support 4 | 5 | ### 6 | ### DO NOT CHANGE THIS FILE 7 | ### 8 | ### The code is auto generated, your change will be overwritten by 9 | ### code generating. 10 | ### 11 | 12 | base_path = '/auth' 13 | 14 | 15 | DefinitionsPassword = {'required': ['password'], 'properties': {'password': {'maxLength': 128, 'minLength': 6, 'type': 'string'}}} 16 | DefinitionsOauthbind = {'required': ['auth_approach', 'identity', 'password'], 'description': '绑定登录后绑定第三方帐号', 'properties': {'auth_approach': {'enum': ['weibo', 'weixin', 'wxapp'], 'type': 'string', 'default': 'mobile', 'description': '绑定第三方帐号 微信 微信公众号 微信小程序'}, 'password': {'type': 'string', 'description': '微博token/微信token'}, 'identity': {'type': 'string', 'description': 'weibo/weixin uid'}}} 17 | DefinitionsError = {'properties': {'text': {'type': 'string'}, 'error_code': {'format': 'int32', 'type': 'integer'}, 'message': {'type': 'string'}}} 18 | DefinitionsApproach = {'required': ['approach'], 'properties': {'approach': {'type': 'string'}, 'is_verified': {'type': 'boolean'}, 'identity': {'type': 'string'}}} 19 | DefinitionsCreatewxappaccount = {'required': ['username', 'password', 'code'], 'properties': {'code': {'type': 'string'}, 'username': {'type': 'string'}, 'password': {'type': 'string'}}} 20 | DefinitionsScopes = {'required': ['scopes'], 'properties': {'scopes': {'items': {'type': 'string'}, 'type': 'array', 'description': 'token 类型'}}} 21 | DefinitionsResetpassword = {'properties': {'old_password': {'maxLength': 128, 'minLength': 6, 'type': 'string'}, 'mobile': {'type': 'string'}, 'new_password': {'maxLength': 128, 'minLength': 6, 'type': 'string'}}} 22 | DefinitionsToken = {'description': 'token', 'properties': {'access_token': {'type': 'string'}}} 23 | DefinitionsUpdatepassword = {'properties': {'new_password': {'maxLength': 128, 'minLength': 6, 'type': 'string'}, 'password': {'maxLength': 128, 'minLength': 6, 'type': 'string'}}} 24 | DefinitionsTokendetail = {'required': ['account_id', 'access_token', 'token_type'], 'description': '返回的token信息', 'properties': {'account_id': {'type': 'string'}, 'nickname': {'type': 'string'}, 'token_type': {'type': 'string', 'default': 'jwt'}, 'access_token': {'type': 'string'}}} 25 | DefinitionsAccount = {'required': ['id'], 'description': 'account 基本信息', 'properties': {'date_created': {'format': 'datetime', 'type': 'string'}, 'id': {'type': 'string'}}} 26 | DefinitionsSuccess = {'properties': {'ok': {'type': 'boolean'}}} 27 | DefinitionsTokencode = {'required': ['code', 'grant_type'], 'properties': {'code': {'type': 'string'}, 'grant_type': {'enum': ['token_code'], 'type': 'string', 'default': 'token_code'}}} 28 | DefinitionsNone = {'type': 'object'} 29 | DefinitionsAuth_approach = {'properties': {'auth_approach': {'enum': ['mobile', 'wxapp', 'weixin', 'weixin_mp'], 'type': 'string', 'default': 'mobile', 'description': '登录方式 手机 微信 微信小程序'}}} 30 | DefinitionsRefreshtoken = {'required': ['refresh_token', 'grant_type'], 'properties': {'refresh_token': {'type': 'string'}, 'grant_type': {'enum': ['refresh_token'], 'type': 'string', 'default': 'refresh_token'}}} 31 | DefinitionsAuthentications = {'description': '用户详细授权数据', 'properties': {'wxapp': {'type': 'string'}, 'mobile': {'type': 'string'}}} 32 | DefinitionsAuthentication = {'required': ['username', 'password'], 'allOf': [DefinitionsAuth_approach, {'type': 'object'}], 'properties': {'username': {'type': 'string', 'description': '手机号//微信open_id/email'}, 'password': {'type': 'string', 'description': '密码/微信token'}, 'grant_type': {'enum': ['password'], 'type': 'string', 'default': 'password', 'description': '认证类型 默认密码'}}, 'optional': ['grant_type', 'auth_approach'], 'type': 'object', 'description': '获取token 登录 使用'} 33 | DefinitionsAccountdetail = {'required': ['id'], 'description': 'account 信息', 'properties': {'authentications': DefinitionsAuthentications, 'avatar': {'type': 'string'}, 'nickname': {'type': 'string'}, 'username': {'type': 'string'}, 'created_time': {'format': 'datetime', 'type': 'string'}, 'id': {'type': 'string'}}} 34 | 35 | validators = { 36 | ('oauth_token_code', 'POST'): {'json': DefinitionsTokencode, 'headers': {'required': ['Authorization'], 'properties': {'Authorization': {'type': 'string', 'description': '格式 (Basic hashkey) 注 hashkey为client_id + client_secret 做BASE64处理'}}}}, 37 | ('accounts_self', 'GET'): {'headers': {'required': ['Authorization'], 'properties': {'Authorization': {'type': 'string'}}}}, 38 | ('oauth_token', 'POST'): {'json': DefinitionsAuthentication, 'args': {'required': [], 'properties': {'code': {'required': False, 'type': 'string', 'description': 'code'}}}, 'headers': {'required': ['Authorization'], 'properties': {'Authorization': {'type': 'string', 'description': '格式 (Basic hashkey) 注 hashkey为client_id + client_secret 做BASE64处理'}}}}, 39 | ('self_password_reset', 'POST'): {'json': DefinitionsResetpassword, 'headers': {'required': ['Authorization'], 'properties': {'Authorization': {'type': 'string', 'description': '格式 (Basic hashkey) 注 hashkey为client_id + client_secret 做BASE64处理'}}}}, 40 | ('self_password', 'POST'): {'json': DefinitionsPassword, 'headers': {'required': ['Authorization'], 'properties': {'Authorization': {'type': 'string'}}}}, 41 | ('self_password', 'PUT'): {'json': DefinitionsUpdatepassword, 'headers': {'required': ['Authorization'], 'properties': {'Authorization': {'type': 'string'}}}}, 42 | ('oauth_token_refresh', 'POST'): {'json': DefinitionsRefreshtoken, 'headers': {'required': ['Authorization'], 'properties': {'Authorization': {'type': 'string', 'description': '格式 (Basic hashkey) 注 hashkey为client_id + client_secret 做BASE64处理'}}}}, 43 | ('accounts_wxapp', 'POST'): {'json': DefinitionsCreatewxappaccount, 'headers': {'required': ['Authorization'], 'properties': {'Authorization': {'type': 'string'}}}}, 44 | } 45 | 46 | filters = { 47 | ('oauth_token_code', 'POST'): {200: {'schema': DefinitionsTokendetail, 'headers': None}}, 48 | ('accounts_self', 'GET'): {200: {'schema': DefinitionsAccountdetail, 'headers': None}}, 49 | ('oauth_token', 'POST'): {201: {'schema': DefinitionsTokendetail, 'headers': None}}, 50 | ('self_password_reset', 'POST'): {200: {'schema': DefinitionsSuccess, 'headers': None}}, 51 | ('self_password', 'POST'): {201: {'schema': DefinitionsPassword, 'headers': None}}, 52 | ('self_password', 'PUT'): {200: {'schema': DefinitionsSuccess, 'headers': None}}, 53 | ('oauth_token_refresh', 'POST'): {200: {'schema': DefinitionsTokendetail, 'headers': None}}, 54 | ('accounts_wxapp', 'POST'): {201: {'schema': DefinitionsAccount, 'headers': None}}, 55 | } 56 | 57 | scopes = { 58 | ('oauth_token_code', 'POST'): ['login'], 59 | ('accounts_self', 'GET'): ['open'], 60 | ('oauth_token', 'POST'): ['login', 'register'], 61 | ('self_password_reset', 'POST'): ['login'], 62 | ('self_password', 'POST'): ['open'], 63 | ('self_password', 'PUT'): ['open'], 64 | ('oauth_token_refresh', 'POST'): ['login'], 65 | ('accounts_wxapp', 'POST'): ['login'], 66 | } 67 | 68 | 69 | class Current(object): 70 | 71 | request = None 72 | 73 | 74 | current = Current() 75 | 76 | 77 | class Security(object): 78 | 79 | def __init__(self): 80 | super(Security, self).__init__() 81 | self._loader = lambda x: [] 82 | 83 | @property 84 | def scopes(self): 85 | return self._loader(current.request) 86 | 87 | def scopes_loader(self, func): 88 | self._loader = func 89 | return func 90 | 91 | security = Security() 92 | 93 | 94 | def merge_default(schema, value, get_first=True): 95 | # TODO: more types support 96 | type_defaults = { 97 | 'integer': 9573, 98 | 'string': 'something', 99 | 'object': {}, 100 | 'array': [], 101 | 'boolean': False 102 | } 103 | 104 | results = normalize(schema, value, type_defaults) 105 | if get_first: 106 | return results[0] 107 | return results 108 | 109 | 110 | def normalize(schema, data, required_defaults=None): 111 | 112 | import six 113 | 114 | if required_defaults is None: 115 | required_defaults = {} 116 | errors = [] 117 | 118 | class DataWrapper(object): 119 | 120 | def __init__(self, data): 121 | super(DataWrapper, self).__init__() 122 | self.data = data 123 | 124 | def get(self, key, default=None): 125 | if isinstance(self.data, dict): 126 | return self.data.get(key, default) 127 | return getattr(self.data, key, default) 128 | 129 | def has(self, key): 130 | if isinstance(self.data, dict): 131 | return key in self.data 132 | return hasattr(self.data, key) 133 | 134 | def keys(self): 135 | if isinstance(self.data, dict): 136 | return list(self.data.keys()) 137 | return list(getattr(self.data, '__dict__', {}).keys()) 138 | 139 | def get_check(self, key, default=None): 140 | if isinstance(self.data, dict): 141 | value = self.data.get(key, default) 142 | has_key = key in self.data 143 | else: 144 | try: 145 | value = getattr(self.data, key) 146 | except AttributeError: 147 | value = default 148 | has_key = False 149 | else: 150 | has_key = True 151 | return value, has_key 152 | 153 | def _merge_dict(src, dst): 154 | for k, v in six.iteritems(dst): 155 | if isinstance(src, dict): 156 | if isinstance(v, dict): 157 | r = _merge_dict(src.get(k, {}), v) 158 | src[k] = r 159 | else: 160 | src[k] = v 161 | else: 162 | src = {k: v} 163 | return src 164 | 165 | def _normalize_dict(schema, data): 166 | result = {} 167 | if not isinstance(data, DataWrapper): 168 | data = DataWrapper(data) 169 | 170 | for _schema in schema.get('allOf', []): 171 | rs_component = _normalize(_schema, data) 172 | _merge_dict(result, rs_component) 173 | 174 | for key, _schema in six.iteritems(schema.get('properties', {})): 175 | # set default 176 | type_ = _schema.get('type', 'object') 177 | 178 | # get value 179 | value, has_key = data.get_check(key) 180 | if has_key: 181 | result[key] = _normalize(_schema, value) 182 | elif 'default' in _schema: 183 | result[key] = _schema['default'] 184 | elif key in schema.get('required', []): 185 | if type_ in required_defaults: 186 | result[key] = required_defaults[type_] 187 | else: 188 | errors.append(dict(name='property_missing', 189 | message='`%s` is required' % key)) 190 | 191 | additional_properties_schema = schema.get('additionalProperties', False) 192 | if additional_properties_schema: 193 | aproperties_set = set(data.keys()) - set(result.keys()) 194 | for pro in aproperties_set: 195 | result[pro] = _normalize(additional_properties_schema, data.get(pro)) 196 | 197 | return result 198 | 199 | def _normalize_list(schema, data): 200 | result = [] 201 | if hasattr(data, '__iter__') and not isinstance(data, dict): 202 | for item in data: 203 | result.append(_normalize(schema.get('items'), item)) 204 | elif 'default' in schema: 205 | result = schema['default'] 206 | return result 207 | 208 | def _normalize_default(schema, data): 209 | if data is None: 210 | return schema.get('default') 211 | else: 212 | return data 213 | 214 | def _normalize(schema, data): 215 | if not schema: 216 | return None 217 | funcs = { 218 | 'object': _normalize_dict, 219 | 'array': _normalize_list, 220 | 'default': _normalize_default, 221 | } 222 | type_ = schema.get('type', 'object') 223 | if not type_ in funcs: 224 | type_ = 'default' 225 | 226 | return funcs[type_](schema, data) 227 | 228 | return _normalize(schema, data), errors 229 | 230 | -------------------------------------------------------------------------------- /apis/auth/validators.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | ### 3 | ### DO NOT CHANGE THIS FILE 4 | ### 5 | ### The code is auto generated, your change will be overwritten by 6 | ### code generating. 7 | ### 8 | from __future__ import absolute_import, print_function 9 | 10 | import re 11 | import json 12 | from datetime import date 13 | from functools import wraps 14 | 15 | import six 16 | from sanic import response 17 | from sanic.exceptions import ServerError 18 | from sanic.response import HTTPResponse 19 | 20 | from werkzeug.datastructures import MultiDict, Headers 21 | from sanic.request import RequestParameters 22 | from jsonschema import Draft4Validator 23 | 24 | from .schemas import ( 25 | validators, filters, scopes, security, base_path, normalize, current) 26 | 27 | 28 | def unpack(value): 29 | """Return a three tuple of data, code, and headers""" 30 | if not isinstance(value, tuple): 31 | return value, 200, {} 32 | 33 | try: 34 | data, code, headers = value 35 | return data, code, headers 36 | except ValueError: 37 | pass 38 | 39 | try: 40 | data, code = value 41 | return data, code, {} 42 | except ValueError: 43 | pass 44 | 45 | return value, 200, {} 46 | 47 | 48 | def _remove_characters(text, deletechars): 49 | return text.translate({ord(x): None for x in deletechars}) 50 | 51 | 52 | def _path_to_endpoint(path): 53 | endpoint = '_'.join(filter(None, re.sub(r'(/|<|>|-)', r'_', path).split('_'))) 54 | _base_path = base_path.strip('/').replace('/', '_').replace('-', '_') 55 | if endpoint.startswith(_base_path): 56 | endpoint = endpoint[len(_base_path)+1:] 57 | return _remove_characters(endpoint, '{}') 58 | 59 | 60 | class JSONEncoder(json.JSONEncoder): 61 | 62 | def default(self, o): 63 | if isinstance(o, date): 64 | return o.isoformat() 65 | return json.JSONEncoder.default(self, o) 66 | 67 | 68 | class SanicValidatorAdaptor(object): 69 | 70 | def __init__(self, schema): 71 | self.validator = Draft4Validator(schema) 72 | 73 | def validate_number(self, type_, value): 74 | try: 75 | return type_(value) 76 | except ValueError: 77 | return value 78 | 79 | def type_convert(self, obj): 80 | if obj is None: 81 | return None 82 | if isinstance(obj, (dict, list)) and not isinstance(obj, RequestParameters): 83 | return obj 84 | if isinstance(obj, Headers): 85 | obj = MultiDict(obj.items()) 86 | result = dict() 87 | 88 | convert_funs = { 89 | 'integer': lambda v: self.validate_number(int, v[0]), 90 | 'boolean': lambda v: v[0].lower() not in ['n', 'no', 'false', '', '0'], 91 | 'null': lambda v: None, 92 | 'number': lambda v: self.validate_number(float, v[0]), 93 | 'string': lambda v: v[0] 94 | } 95 | 96 | def convert_array(type_, v): 97 | func = convert_funs.get(type_, lambda v: v[0]) 98 | return [func([i]) for i in v] 99 | 100 | for k, values in obj.items(): 101 | prop = self.validator.schema['properties'].get(k, {}) 102 | type_ = prop.get('type') 103 | fun = convert_funs.get(type_, lambda v: v[0]) 104 | if type_ == 'array': 105 | item_type = prop.get('items', {}).get('type') 106 | result[k] = convert_array(item_type, values) 107 | else: 108 | result[k] = fun(values) 109 | return result 110 | 111 | def validate(self, value): 112 | value = self.type_convert(value) 113 | errors = list(e.message for e in self.validator.iter_errors(value)) 114 | return normalize(self.validator.schema, value)[0], errors 115 | 116 | 117 | def request_validate(view): 118 | 119 | @wraps(view) 120 | def wrapper(*args, **kwargs): 121 | request = args[1] 122 | endpoint = _path_to_endpoint(request.uri_template) 123 | current.request = request 124 | # scope 125 | if (endpoint, request.method) in scopes and not set( 126 | scopes[(endpoint, request.method)]).issubset(set(security.scopes)): 127 | raise ServerError('403', status_code=403) 128 | # data 129 | method = request.method 130 | if method == 'HEAD': 131 | method = 'GET' 132 | locations = validators.get((endpoint, method), {}) 133 | for location, schema in locations.items(): 134 | value = getattr(request, location, MultiDict()) 135 | if value is None: 136 | value = MultiDict() 137 | validator = SanicValidatorAdaptor(schema) 138 | result, errors = validator.validate(value) 139 | if errors: 140 | raise ServerError('Unprocessable Entity', status_code=422) 141 | return view(*args, **kwargs) 142 | 143 | return wrapper 144 | 145 | 146 | def response_filter(view): 147 | 148 | @wraps(view) 149 | async def wrapper(*args, **kwargs): 150 | request = args[1] 151 | resp = view(*args, **kwargs) 152 | 153 | from inspect import isawaitable 154 | if isawaitable(resp): 155 | resp = await resp 156 | if isinstance(resp, HTTPResponse): 157 | return resp 158 | 159 | endpoint = _path_to_endpoint(request.uri_template) 160 | method = request.method 161 | if method == 'HEAD': 162 | method = 'GET' 163 | filter = filters.get((endpoint, method), None) 164 | if not filter: 165 | return resp 166 | 167 | headers = None 168 | status = None 169 | if isinstance(resp, tuple): 170 | resp, status, headers = unpack(resp) 171 | 172 | if len(filter) == 1: 173 | if six.PY3: 174 | status = list(filter.keys())[0] 175 | else: 176 | status = filter.keys()[0] 177 | 178 | schemas = filter.get(status) 179 | if not schemas: 180 | # return resp, status, headers 181 | raise ServerError('`%d` is not a defined status code.' % status, 500) 182 | 183 | resp, errors = normalize(schemas['schema'], resp) 184 | if schemas['headers']: 185 | headers, header_errors = normalize( 186 | {'properties': schemas['headers']}, headers) 187 | errors.extend(header_errors) 188 | if errors: 189 | raise ServerError('Expectation Failed', 500) 190 | 191 | return response.json( 192 | resp, 193 | status=status, 194 | headers=headers, 195 | ) 196 | 197 | return wrapper -------------------------------------------------------------------------------- /apis/custom_errors.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | ### 3 | ### DO NOT CHANGE THIS FILE 4 | ### 5 | ### The code is auto generated, your change will be overwritten by 6 | ### code generating. 7 | ### 8 | 9 | from sanic.exceptions import SanicException 10 | 11 | def add_status_code(code): 12 | """ 13 | Decorator used for adding exceptions to _sanic_exceptions. 14 | """ 15 | def class_decorator(cls): 16 | cls.status_code = code 17 | return cls 18 | return class_decorator 19 | 20 | 21 | class JSONException(SanicException): 22 | 23 | def __init__(self, code, message=None, errors=None, status_code=None): 24 | super().__init__(message) 25 | self.error_code = code 26 | self.message = message 27 | self.errors = errors 28 | 29 | if status_code is not None: 30 | self.status_code = status_code 31 | 32 | 33 | @add_status_code(422) 34 | class UnprocessableEntity(JSONException): 35 | pass 36 | 37 | @add_status_code(401) 38 | class Unauthorized(JSONException): 39 | pass 40 | 41 | 42 | @add_status_code(403) 43 | class Forbidden(JSONException): 44 | pass 45 | 46 | 47 | @add_status_code(500) 48 | class ServerError(JSONException): 49 | pass -------------------------------------------------------------------------------- /apis/exception.py: -------------------------------------------------------------------------------- 1 | #! -*- coding: utf-8 -*- 2 | from sanic.exceptions import SanicException 3 | 4 | 5 | error_codes = { 6 | 'wxapp_not_registered': ('The wxapp not registered', '微信没有注册'), 7 | 'invalid_wxapp_code': ('Invalid wxapp code', '无效的code'), 8 | 'invalid_token': ('Invalid token', '无效的token'), 9 | 'wxapp_already_registered': ('The weixin app already registered', '账号已被注册'), 10 | } 11 | 12 | def add_status_code(code): 13 | """ 14 | Decorator used for adding exceptions to _sanic_exceptions. 15 | """ 16 | def class_decorator(cls): 17 | cls.status_code = code 18 | return cls 19 | return class_decorator 20 | 21 | 22 | class MetisException(SanicException): 23 | 24 | def __init__(self, code, message=None, text=None, status_code=None): 25 | super().__init__(message) 26 | self.error_code = code 27 | _message, _text = error_codes.get(code, (None, None)) 28 | self.message = message or _message 29 | self.text = text or _text 30 | 31 | if status_code is not None: 32 | self.status_code = status_code 33 | 34 | 35 | @add_status_code(404) 36 | class NotFound(MetisException): 37 | pass 38 | 39 | 40 | @add_status_code(400) 41 | class BadRequest(MetisException): 42 | pass 43 | 44 | 45 | @add_status_code(401) 46 | class Unauthorized(MetisException): 47 | pass 48 | 49 | 50 | @add_status_code(403) 51 | class Forbidden(MetisException): 52 | pass 53 | 54 | 55 | @add_status_code(408) 56 | class RequestTimeout(MetisException): 57 | pass 58 | 59 | 60 | @add_status_code(500) 61 | class ServerError(MetisException): 62 | pass -------------------------------------------------------------------------------- /apis/helpers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from functools import wraps 3 | 4 | import arrow 5 | import dateutil.parser 6 | from dateutil import tz 7 | 8 | 9 | def str_to_time(s, time_zone='Asia/Shanghai'): 10 | """ 11 | str time to datetime 12 | """ 13 | tzinfo = tz.gettz(time_zone) 14 | if not s: 15 | return None 16 | try: 17 | dt = dateutil.parser.parse(s).replace(tzinfo=tzinfo) 18 | return dt 19 | except ValueError: 20 | raise ValueError('%s parsed error' % s) 21 | 22 | 23 | def get_offset_limit(args): 24 | """ 25 | return offset limit 26 | """ 27 | if 'offset' in args and 'limit' in args: 28 | try: 29 | offset = int(args.get('offset', 0)) 30 | except ValueError: 31 | offset = 0 32 | try: 33 | limit = int(args.get('limit', 20)) 34 | except ValueError: 35 | limit = 0 36 | else: 37 | try: 38 | page = int(args.get('page', 1)) 39 | except ValueError: 40 | page = 1 41 | try: 42 | limit = int(args.get('per_page', 20)) 43 | except ValueError: 44 | limit = 20 45 | offset = limit * (page - 1) 46 | return offset, limit 47 | 48 | 49 | def format_result(fields=None, **formats): 50 | ''' 51 | :param data: dict 52 | :param fields: list 53 | :param formats: field target type date_created: ISOString 54 | :return: 55 | ''' 56 | def decorator(func): 57 | 58 | @wraps(func) 59 | async def wrapper(*args, **kwargs): 60 | data, _ = await func(*args, **kwargs) 61 | for field in fields: 62 | value = getattr(data, field) 63 | format = formats.get(field, 'ISOString') 64 | if format == 'ISOString': 65 | data[field] = arrow.Arrow.fromdate(value).isoformat() 66 | return data 67 | return wrapper 68 | return decorator 69 | 70 | 71 | def split_datetime(datetime, date_format="YYYY-MM-DD", time_format="HH:MM"): 72 | ''' 73 | :param datetime: datetime object 74 | :return: date str and time str ex: 2017-03-02, 12:09 75 | ''' 76 | tzinfo = tz.gettz('Asia/Shanghai') 77 | adatetime = arrow.Arrow.fromdate(datetime).to(tzinfo) 78 | return adatetime.format(date_format), adatetime.format(time_format) 79 | -------------------------------------------------------------------------------- /apis/models/__init__.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from bson.objectid import ObjectId 4 | from mongoengine import connect 5 | from weixin.helper import safe_char, smart_bytes 6 | 7 | from apis.settings import Config 8 | 9 | # Establish a connection to the database. 10 | connect(Config.MONGO_DATABASE) 11 | 12 | 13 | def generation_objectid(): 14 | random_str = ''.join(random.sample(safe_char[:-4], 12)) 15 | # return random_str 16 | return ObjectId(smart_bytes(random_str)) 17 | 18 | 19 | class ObjectModel(object): 20 | 21 | def __init__(self, *args, **kwargs): 22 | for k, v in kwargs.items(): 23 | setattr(self, k, v) 24 | 25 | @classmethod 26 | def object_from_dictionary(cls, entry): 27 | # make dict keys all strings 28 | if entry is None: 29 | return "" 30 | entry_str_dict = dict([(str(key), value) for key, value in entry.items()]) 31 | return cls(**entry_str_dict) 32 | 33 | def __repr__(self): 34 | return str(self) 35 | 36 | def __str__(self): 37 | return self.__unicode__() 38 | -------------------------------------------------------------------------------- /apis/models/choice.py: -------------------------------------------------------------------------------- 1 | #! -*- coding: utf-8 -*- 2 | from datetime import datetime 3 | 4 | from apis.models import Model 5 | 6 | 7 | class Test(Model): 8 | 9 | ''' 10 | 测试 11 | :param _id: 测试ID 12 | :param creator_id: 用户ID 13 | :param title: 测试 title 14 | :param description: 测试描述 15 | :param remark: 备注 16 | :param start_time: 开始时间 17 | :param end_time: 结束时间 18 | :param status: 状态(草稿|发布|下线) 19 | :param created_time: 创建时间 20 | ''' 21 | 22 | __collection__ = 'choice' 23 | __default_fields__ = { 24 | 'created_time': datetime.utcnow() 25 | } 26 | 27 | 28 | class Question(Model): 29 | 30 | ''' 31 | 题目: 32 | :param _id: 问题 ID 33 | :param title: 题目 34 | :param test_id: 所属测试 ID 35 | :param type: 题目类型 'single_choice|multiple_choice' 36 | :param options: 选项 37 | [ 38 | '这是第一个选项', 39 | '这是第二个选项', 40 | ] 41 | :param answers: [1, 2], 42 | :param index: 1, 43 | ''' 44 | -------------------------------------------------------------------------------- /apis/models/model.py: -------------------------------------------------------------------------------- 1 | #! -*- coding: utf-8 -*- 2 | import copy 3 | 4 | from six import with_metaclass 5 | from bson.objectid import ObjectId 6 | from pymongo import MongoClient 7 | from pymongo import ReturnDocument 8 | from apis.settings import Config 9 | 10 | 11 | pyclient = MongoClient(Config.MONGO_MASTER_URL) 12 | 13 | 14 | class ObjectModel(object): 15 | 16 | def __init__(self, *args, **kwargs): 17 | for k, v in kwargs.items(): 18 | setattr(self, k, v) 19 | 20 | @classmethod 21 | def object_from_dictionary(cls, entry): 22 | # make dict keys all strings 23 | if entry is None: 24 | return "" 25 | entry_str_dict = dict([(str(key), value) for key, value in entry.items()]) 26 | return cls(**entry_str_dict) 27 | 28 | def __repr__(self): 29 | return str(self) 30 | 31 | def __str__(self): 32 | return self.__unicode__() 33 | 34 | 35 | class ModelMetaclass(type): 36 | """ 37 | Metaclass of the Model. 38 | """ 39 | __collection__ = None 40 | 41 | def __init__(cls, name, bases, attrs): 42 | super(ModelMetaclass, cls).__init__(name, bases, attrs) 43 | cls.db = pyclient[Config.MONGO_DATABASE] 44 | if cls.__collection__: 45 | cls.collection = cls.db[cls.__collection__] 46 | 47 | 48 | class Model(with_metaclass(ModelMetaclass, object)): 49 | 50 | ''' 51 | Model 52 | ''' 53 | 54 | __collection__ = 'model_base' 55 | 56 | @classmethod 57 | def get(cls, _id=None, **kwargs): 58 | if _id: 59 | doc = cls.collection.find_one({'_id': ObjectId(_id)}) 60 | else: 61 | doc = cls.collection.find_one(kwargs) 62 | if doc and doc.get('_id', None): 63 | doc['id'] = str(doc['_id']) 64 | return doc 65 | 66 | @classmethod 67 | def find(cls, filter=None, projection=None, skip=0, limit=20, sorts=None, **kwargs): 68 | if sorts: 69 | docs = cls.collection.find(filter=filter, 70 | projection=projection, 71 | **kwargs).skip(skip).limit(limit).sort(sorts) 72 | else: 73 | docs = cls.collection.find(filter=filter, 74 | projection=projection, 75 | **kwargs).skip(skip).limit(limit) 76 | results = [] 77 | for doc in docs: 78 | if doc.get('_id', None): 79 | doc['id'] = str(doc['_id']) 80 | results.append(doc) 81 | return results 82 | 83 | @classmethod 84 | def insert(cls, **kwargs): 85 | params = getattr(cls, '__default_fields__', {}) 86 | params.update(kwargs) 87 | _id = cls.collection.insert_one(params.copy()).inserted_id 88 | kwargs['id'] = str(_id) 89 | return kwargs 90 | 91 | @classmethod 92 | def update_or_insert(cls, fields=None, **kwargs): 93 | ''' 94 | :param fields: list filter fields 95 | :param kwargs: update fields 96 | :return: 97 | ''' 98 | if fields: 99 | filters = {field: kwargs[field] for field in fields if kwargs.get(field)} 100 | doc = cls.collection.find_one_and_update( 101 | filters, kwargs, return_document=ReturnDocument.AFTER, upsert=True) 102 | else: 103 | doc = cls.collection.insert_one(kwargs) 104 | return doc 105 | 106 | 107 | @classmethod 108 | def bulk_inserts(cls, *params): 109 | ''' 110 | :param params: document list 111 | :return: 112 | ''' 113 | results = cls.collection.insert_many(params) 114 | return results 115 | 116 | @classmethod 117 | def find_one_and_update(cls, filter, update=None, 118 | return_document=ReturnDocument.AFTER, **kwargs): 119 | result = cls.collection.find_one_and_update(filter, update=update, 120 | return_document=return_document, 121 | **kwargs) 122 | _id = result.get('_id') 123 | if _id: 124 | result['id'] = str(_id) 125 | return result 126 | 127 | @classmethod 128 | def update_one(cls, filter, **kwargs): 129 | result = cls.collection.update_one(filter, **kwargs) 130 | return result 131 | 132 | @classmethod 133 | def update_many(cls, filter, **kwargs): 134 | results = cls.collection.update_many(filter, **kwargs) 135 | return results 136 | 137 | @classmethod 138 | def delete_one(cls, _id=None, **filter): 139 | if _id: 140 | cls.collection.delete_one({'_id': ObjectId(_id)}) 141 | else: 142 | cls.collection.delete_one(filter) 143 | 144 | @classmethod 145 | def delete_many(cls, **filter): 146 | cls.collection.delete_many(filter) 147 | -------------------------------------------------------------------------------- /apis/models/oauth.py: -------------------------------------------------------------------------------- 1 | #! -*- coding: utf-8 -*- 2 | import bcrypt 3 | from hashlib import sha256 4 | from binascii import hexlify 5 | from datetime import datetime 6 | 7 | from mongoengine import EmbeddedDocument, Document 8 | from mongoengine.fields import (StringField, DateTimeField, 9 | ObjectIdField, DictField, 10 | ListField, EmbeddedDocumentField) 11 | 12 | __all__ = ['Account', 'OAuth2Client'] 13 | 14 | 15 | class HasedPassword(object): 16 | 17 | HASH_LOG_ROUNDS = 4 # 值越大 hash 速度越慢,越安全,4 最小值 18 | 19 | def __init__(self, hashed): 20 | super(HasedPassword, self).init() 21 | self.hashed = hashed 22 | 23 | def __eq__(self, plain): 24 | return self.check(plain) 25 | 26 | def __ne__(self, plain): 27 | return not self.check(plain) 28 | 29 | def check(self, plain): 30 | plain = HasedPassword.digest(plain) 31 | return bcrypt.hashpw(plain, self.hashed) == self.hashed 32 | 33 | @classmethod 34 | def digest(cls, text): 35 | if not isinstance(text, bytes): 36 | text = text.encode('utf-8', 'strict') 37 | return hexlify(sha256(text).digest()) 38 | 39 | @classmethod 40 | def hash(cls, plain): 41 | plain = cls.digest(plain) 42 | return bcrypt.hashpw(plain, bcrypt.gensalt(cls.HASH_LOG_ROUNDS)) 43 | 44 | 45 | class Authentications(EmbeddedDocument): 46 | 47 | wxapp = StringField() 48 | mobile = StringField() 49 | 50 | 51 | class Account(Document): 52 | 53 | ''' 54 | 帐号 55 | :param _id: account ID 56 | :param username: 用户ID 57 | :param created_time: 创建时间 58 | :param password: 密码 59 | :param authentications: 认证信息 {'wxapp': 'openid', 'mobile': 'mobile_num'} 60 | :param status: 状态(active|发布|forbidden) 61 | ''' 62 | 63 | meta = { 64 | 'collection': 'account', 65 | 'indexes': [ 66 | '$username', # text index 67 | ] 68 | } 69 | 70 | id = ObjectIdField(primary_key=True, required=True) 71 | nickname = StringField(required=True) 72 | avatar = StringField(required=True) 73 | username = StringField() 74 | password = StringField() 75 | authentications = EmbeddedDocumentField(Authentications) 76 | created_time = DateTimeField(default=datetime.utcnow()) 77 | updated_time = DateTimeField() 78 | 79 | @classmethod 80 | def get_by_wxapp(cls, openid): 81 | account = cls.objects(authentications__wxapp=openid).first() 82 | return account 83 | 84 | 85 | class OAuth2Client(Document): 86 | 87 | meta = {'collection': 'oauth2_client'} 88 | 89 | id = ObjectIdField(primary_key=True, required=True) 90 | client_id = StringField(required=True) 91 | account_id = ObjectIdField(required=True) 92 | secret = StringField(required=True) 93 | scopes = ListField(required=True) 94 | created_time = DateTimeField(default=datetime.utcnow()) 95 | -------------------------------------------------------------------------------- /apis/models/test.py: -------------------------------------------------------------------------------- 1 | #! -*- coding: utf-8 -*- 2 | 3 | from datetime import datetime 4 | 5 | from weixin.helper import smart_str 6 | 7 | from mongoengine import EmbeddedDocument, Document 8 | from mongoengine.fields import (StringField, DateTimeField, 9 | ObjectIdField, DictField, IntField, 10 | ListField, ReferenceField, 11 | BooleanField) 12 | 13 | from apis.helpers import split_datetime 14 | from apis.models.oauth import Account 15 | 16 | 17 | class Question(Document): 18 | 19 | ''' 20 | 题目: 21 | :param _id: 问题 ID 22 | :param title: 题目 23 | :param test_id: 所属测试 ID 24 | :param number: 第几题 25 | :param type: 题目类型 'single_choice|multiple_choice' 26 | :param options: 选项 27 | [ 28 | {'value': '这是第一个选项', 'index': 0, 'is_checked': True}, 29 | {'value': '这是第二个选项', 'index': 1, 'is_checked': False}, 30 | ] 31 | ''' 32 | 33 | def __getattribute__(self, name): 34 | if name == 'id': 35 | id = super(Question, self).__getattribute__(name) 36 | return smart_str(id) 37 | return super(Question, self).__getattribute__(name) 38 | 39 | meta = { 40 | "collection_name": 'question', 41 | 'indexes': [ 42 | 'title', 43 | '$title', # text index 44 | ('test_id', 'number'), 45 | ] 46 | } 47 | 48 | id = ObjectIdField(primary_key=True, required=True) 49 | test_id = StringField(required=True) 50 | title = StringField(required=True) 51 | type = StringField(required=True, default='single_choice') 52 | number = IntField(required=True, default=0) 53 | options = ListField(required=True, default=[]) 54 | created_time = DateTimeField(default=datetime.utcnow()) 55 | updated_time = DateTimeField() 56 | 57 | 58 | class Test(Document): 59 | 60 | ''' 61 | 测试 62 | :param _id: 测试ID 63 | :param creator_id: 用户ID 64 | :param title: 测试 title 65 | :param description: 测试描述 66 | :param remark: 备注 67 | :param start_time: 开始时间 68 | :param end_time: 结束时间 69 | :param order_score: 排序值 70 | :param status: 状态(草稿|发布|下线) 71 | :param is_sticky: 置顶(首页 banner) 72 | :param is_digest: 精华(首页精选) 73 | :param created_time: 创建时间 74 | ''' 75 | 76 | STATUS_DRAFT = 'draft' 77 | STATUS_PUBLISHED = 'published' 78 | STATUS_WITHDRAW = 'withdraw' 79 | 80 | def __getattribute__(self, name): 81 | if name == 'id': 82 | id = super(Test, self).__getattribute__(name) 83 | return smart_str(id) 84 | return super(Test, self).__getattribute__(name) 85 | 86 | id = ObjectIdField(primary_key=True, required=True) 87 | creator_id = StringField(required=True) 88 | title = StringField(required=True) 89 | description = StringField(required=True) 90 | image = StringField() 91 | remark = StringField() 92 | status = StringField(required=True, default='draft') 93 | total_score = IntField(default=100) 94 | question_count = IntField(required=True, default=0) 95 | participate_number = IntField(required=True, default=0) 96 | start_time = DateTimeField() 97 | end_time = DateTimeField() 98 | is_sticky = BooleanField() 99 | is_digest = BooleanField() 100 | order_score = IntField() 101 | created_time = DateTimeField(default=datetime.utcnow()) 102 | updated_time = DateTimeField() 103 | # questions = ReferenceField('Question') 104 | 105 | meta = { 106 | "collection_name": 'test', 107 | 'indexes': [ 108 | 'title', 109 | '$title', # text index 110 | ('creator_id', 'status', '-created_time'), 111 | ('is_sticky', 'status', '-order_score'), 112 | ('is_digest', 'status', '-order_score'), 113 | ('status', '-participate_number') 114 | ] 115 | } 116 | 117 | @property 118 | def creator(self): 119 | account = Account.objects(id=self.creator_id).first() 120 | return account 121 | 122 | def get_start_time_value(self): 123 | if self.start_time: 124 | date_start, time_start = split_datetime(self.start_time) 125 | return date_start, time_start 126 | return None, None 127 | 128 | @property 129 | def time_start(self): 130 | _, time_start = self.get_start_time_value() 131 | return time_start 132 | 133 | @property 134 | def date_start(self): 135 | date_start, _ = self.get_start_time_value() 136 | return date_start 137 | 138 | def get_end_time_value(self): 139 | if self.end_time: 140 | date_end, time_end = split_datetime(self.end_time) 141 | return date_end, time_end 142 | return None, None 143 | 144 | @property 145 | def time_end(self): 146 | _, time_end = self.get_end_time_value() 147 | return time_end 148 | 149 | @property 150 | def date_end(self): 151 | date_end, _ = self.get_end_time_value() 152 | return date_end 153 | 154 | 155 | class Answer(Document): 156 | ''' 157 | 测试 158 | :param id: 答案ID 159 | :param account_id: 用户ID 160 | :param test_id: 测试 id 161 | :param score: 分数 162 | :param status: 测试状态(pending|finished) 163 | :param created_time: 开始答题时间 164 | :param updated_time: 最后答题时间 165 | :param answers: 答案 {question_id: [options]} 166 | ''' 167 | 168 | STATUS_FINISHED = 'finished' 169 | STATUS_PENDING = 'pending' 170 | 171 | id = ObjectIdField(primary_key=True) 172 | test_id = StringField(required=True) 173 | account_id = StringField(required=True) 174 | score = IntField(default=0) 175 | answers = DictField(default={}) 176 | status = StringField(required=True, default=STATUS_PENDING) 177 | created_time = DateTimeField(default=datetime.utcnow()) 178 | updated_time = DateTimeField() 179 | 180 | meta = { 181 | "collection_name": 'answer', 182 | 'indexes': [ 183 | ('account_id', 'status', '-score'), 184 | ('account_id', 'status', '-updated_time'), 185 | ] 186 | } 187 | -------------------------------------------------------------------------------- /apis/settings.py: -------------------------------------------------------------------------------- 1 | from os import environ 2 | from six.moves.urllib.parse import urlparse 3 | 4 | try: 5 | from .local_settings import Config as LConfig 6 | class Config(LConfig): 7 | pass 8 | except: 9 | class Config(object): 10 | 11 | DEBUG = False 12 | TESTING = False 13 | 14 | SECRET_KEY = 'MDzEfbh7e63UjRCDepoKN8NiFNBssezyL' 15 | 16 | MONGO_MASTER_HOST = environ.get('MONGO_PORT_27017_TCP_ADDR', '127.0.0.1') 17 | MONGO_MASTER_PORT = environ.get('MONGO_PORT_27017_TCP_PORT', '27017') 18 | MONGO_DATABASE = environ.get('MONGO_DATABASE', 'metis') 19 | MONGO_MASTER_URL = 'mongodb://%s:%s/%s' % (MONGO_MASTER_HOST, 20 | MONGO_MASTER_PORT, 21 | MONGO_DATABASE) 22 | 23 | APP_TRANSPORT = environ.get('APP_TRANSPORT', 'http') 24 | APP_DOMAIN = environ.get('APP_DOMAIN', 'http://metis.gusibi.mobi') 25 | API_DOMAIN = environ.get('API_DOMAIN', 'http://metis.gusibi.mobi') 26 | DOMAIN = '%s://%s' % (APP_TRANSPORT, urlparse(APP_DOMAIN).netloc) 27 | 28 | # JWT 29 | ISS = environ.get('ISS', 'iss') 30 | AUDIENCE = environ.get('AUDIENCE', 'audience') 31 | 32 | # 微信 小程序账号信息 33 | WXAPP_ID = environ.get('WXAPP_ID', 'appid') 34 | WXAPP_SECRET = environ.get('WXAPP_SECRET', 'secret') 35 | WXAPP_TOKEN = environ.get('WXAPP_TOKEN', 'token') 36 | WXAPP_ENCODINGAESKEY = environ.get('WXAPP_ENCODINGAESKEY', '') 37 | 38 | # cdn 使用 腾讯云 39 | QCOS_APPID = environ.get('QCOS_APPID', 'appid') 40 | QCOS_SECRET_ID = environ.get('QCOS_SECRET_ID', 'id') 41 | QCOS_SECRET_KEY = environ.get('QCOS_SECRET_KEY', 'KEY') 42 | QCOS_BUCKET_NAME = environ.get('QCOS_BUCKET_NAME', 'name') 43 | -------------------------------------------------------------------------------- /apis/v1/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | 4 | from sanic import Blueprint 5 | 6 | from apis.verification import verify_request, current_account 7 | 8 | from .routes import routes 9 | from .validators import security 10 | 11 | 12 | @security.scopes_loader 13 | def current_scopes(request): 14 | is_validate, token = verify_request(request) 15 | if is_validate and token: 16 | if isinstance(token, list): 17 | return token 18 | current_account.id = token.sub 19 | return token.scopes 20 | return [] 21 | 22 | bp = Blueprint('v1', url_prefix='/v1') # 需要加 url_prefix 23 | 24 | for route in routes: 25 | route.pop('endpoint', None) 26 | bp.add_route(route.pop('resource'), *route.pop('urls'), **route) 27 | -------------------------------------------------------------------------------- /apis/v1/api/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | 4 | import inspect 5 | 6 | from sanic.views import HTTPMethodView 7 | 8 | from ..validators import request_validate, response_filter 9 | 10 | before_decorators = [request_validate] 11 | after_decorators = [response_filter] 12 | 13 | methods = ['get', 'put', 'post', 'delete'] 14 | 15 | 16 | def add_before_decorators(model_class): 17 | for name, m in inspect.getmembers(model_class, inspect.isfunction): 18 | if name in methods: 19 | for dec in before_decorators: 20 | m = dec(m) 21 | setattr(model_class, name, m) 22 | 23 | 24 | def add_after_decorators(model_class): 25 | for name, m in inspect.getmembers(model_class, inspect.isfunction): 26 | if name in methods: 27 | for dec in after_decorators: 28 | m = dec(m) 29 | setattr(model_class, name, m) 30 | 31 | 32 | class APIMetaclass(type): 33 | """ 34 | Metaclass of the Model. 35 | """ 36 | def __init__(cls, name, bases, attrs): 37 | super(APIMetaclass, cls).__init__(name, bases, attrs) 38 | add_before_decorators(cls) 39 | add_after_decorators(cls) 40 | 41 | 42 | class Resource(HTTPMethodView, metaclass=APIMetaclass): 43 | 44 | 45 | pass -------------------------------------------------------------------------------- /apis/v1/api/qc_cos_config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from time import time 4 | from sanic.response import text 5 | 6 | from qcloud_cos.cos_auth import Auth 7 | 8 | from apis.settings import Config 9 | 10 | from . import Resource 11 | 12 | 13 | class QcCosConfig(Resource): 14 | 15 | async def get(self, request): 16 | auth = Auth(appid=Config.QCOS_APPID, 17 | secret_id=Config.QCOS_SECRET_ID, 18 | secret_key=Config.QCOS_SECRET_KEY) 19 | expired = time() + 3600 20 | dir_name = request.raw_args.get('cos_path', '/xrzeti') 21 | sign = auth.sign_more(Config.QCOS_BUCKET_NAME, 22 | cos_path=dir_name, 23 | expired=expired) 24 | return {"sign": sign}, 200 -------------------------------------------------------------------------------- /apis/v1/api/self_testings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from apis.helpers import get_offset_limit 4 | from apis.models.test import Answer, Test 5 | from apis.verification import current_account 6 | 7 | from . import Resource 8 | 9 | 10 | class SelfTestings(Resource): 11 | 12 | async def get(self, request): 13 | filter = {'account_id': current_account.id} 14 | status = request.args.get('status') 15 | if status: 16 | filter['status'] = status 17 | offset, limit = get_offset_limit(request.raw_args) 18 | answers = (Answer.objects(**filter) 19 | .order_by('-update_time') 20 | .skip(offset).limit(limit)) 21 | test_ids = [a.test_id for a in answers] 22 | tests = Test.objects(id__in=test_ids).all() 23 | return tests, 200 24 | -------------------------------------------------------------------------------- /apis/v1/api/self_tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from apis.models import generation_objectid 4 | from apis.models.test import Test 5 | from apis.helpers import get_offset_limit, str_to_time 6 | from apis.verification import current_account 7 | 8 | from . import Resource 9 | 10 | 11 | class SelfTests(Resource): 12 | 13 | async def get(self, request): 14 | filter = {'creator_id': current_account.id} 15 | status = request.args.get('status') 16 | if status and status == 'published': 17 | filter['status'] = status 18 | elif status and status == 'draft': 19 | filter['status__in'] = [status, 'withdraw'] 20 | offset, limit = get_offset_limit(request.raw_args) 21 | tests = Test.objects(**filter).skip(offset).limit(limit).all() 22 | return tests, 200 23 | 24 | async def post(self, request): 25 | request.json['creator_id'] = current_account.id 26 | start_time = request.json.get('start_time') 27 | if start_time: 28 | request.json['start_time'] = str_to_time(start_time) 29 | end_time = request.json.get('end_time') 30 | if end_time: 31 | request.json['end_time'] = str_to_time(end_time) 32 | request.json['id'] = generation_objectid() 33 | test = Test(**request.json).save() 34 | return test, 201 35 | -------------------------------------------------------------------------------- /apis/v1/api/self_tests_id.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from bson.objectid import ObjectId 3 | 4 | from apis.models.test import Test 5 | from apis.verification import current_account 6 | from apis.exception import NotFound, BadRequest 7 | from apis.helpers import format_result, split_datetime, str_to_time 8 | 9 | from . import Resource 10 | 11 | 12 | class SelfTestsId(Resource): 13 | 14 | def _get_test(self, id): 15 | test = Test.objects(id=id).first() 16 | if not test or test.creator_id != current_account.id: 17 | raise NotFound('test_not_found') 18 | return test 19 | 20 | async def get(self, request, id): 21 | test = self._get_test(id) 22 | return test, 200 23 | 24 | async def put(self, request, id): 25 | test = self._get_test(id) 26 | start_time = request.json.get('start_time') 27 | if start_time: 28 | request.json['start_time'] = str_to_time(start_time) 29 | end_time = request.json.get('end_time') 30 | if end_time: 31 | request.json['end_time'] = str_to_time(end_time) 32 | test.update(**request.json) 33 | test.save() 34 | return test, 200 35 | 36 | async def delete(self, request, id): 37 | test = self._get_test(id) 38 | if test.status == Test.STATUS_PUBLISHED: 39 | raise BadRequest('invalid_status') 40 | test.delete() 41 | return {}, 204 42 | -------------------------------------------------------------------------------- /apis/v1/api/self_tests_id_publish.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from apis.models.test import Test 4 | from apis.verification import current_account 5 | from apis.exception import NotFound, BadRequest 6 | 7 | from . import Resource 8 | 9 | 10 | class SelfTestsIdPublish(Resource): 11 | 12 | def _get_test(self, id): 13 | test = Test.objects(id=id).first() 14 | if not test or test.creator_id != current_account.id: 15 | raise NotFound('test_not_found') 16 | return test 17 | 18 | async def put(self, request, id): 19 | test = self._get_test(id) 20 | if not test.question_count: 21 | raise BadRequest('no_questions') 22 | test.update(status=Test.STATUS_PUBLISHED) 23 | return test, 200 24 | 25 | async def delete(self, request, id): 26 | test = self._get_test(id) 27 | if test.status == Test.STATUS_PUBLISHED: 28 | test.update(status=Test.STATUS_WITHDRAW) 29 | return {}, 204 30 | -------------------------------------------------------------------------------- /apis/v1/api/self_tests_id_questions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from apis.models.test import Test, Question 4 | from apis.verification import current_account 5 | from apis.exception import NotFound 6 | 7 | from apis.models import generation_objectid 8 | 9 | from . import Resource 10 | 11 | 12 | class SelfTestsIdQuestions(Resource): 13 | 14 | def _get_test(self, id): 15 | test = Test.objects(id=id).first() 16 | if not test or test.creator_id != current_account.id: 17 | raise NotFound('account_not_found') 18 | return test 19 | 20 | async def get(self, request, id): 21 | test = self._get_test(id) 22 | questions = Question.objects(test_id=test.id).order_by('number').all() 23 | return questions, 200 24 | 25 | async def post(self, request, id): 26 | test = self._get_test(id) 27 | question_count = Question.objects(test_id=id).count() 28 | request.json['test_id'] = test.id 29 | request.json['number'] = question_count 30 | request.json['id'] = generation_objectid() 31 | question = Question(**request.json).save() 32 | # update test questions count 33 | test.update(question_count=question_count + 1) 34 | test.save() 35 | return question, 201 36 | -------------------------------------------------------------------------------- /apis/v1/api/self_tests_test_id_questions_id.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from bson.objectid import ObjectId 4 | 5 | from apis.models.test import Test, Question 6 | from apis.verification import current_account 7 | from apis.exception import NotFound 8 | 9 | from . import Resource 10 | 11 | 12 | class SelfTestsTestIdQuestionsId(Resource): 13 | 14 | async def put(self, request, test_id, id): 15 | test = Test.objects(id=test_id).first() 16 | if not test or test.creator_id != current_account.id: 17 | raise NotFound('test_not_found') 18 | question = Question.objects(id=id, test_id=test_id).first() 19 | if not question: 20 | raise NotFound('question_not_found') 21 | question.udpate(**request.json) 22 | question.save() 23 | return question, 200 24 | -------------------------------------------------------------------------------- /apis/v1/api/tests_banner.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from apis.models.test import Test 4 | 5 | from . import Resource 6 | 7 | 8 | class TestsBanner(Resource): 9 | 10 | async def get(self, request): 11 | tests = (Test.objects(status=Test.STATUS_PUBLISHED, is_sticky=True) 12 | .order_by('-participate_number') 13 | .skip(0).limit(10)) 14 | return tests, 200, None 15 | -------------------------------------------------------------------------------- /apis/v1/api/tests_handpick.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from apis.models.test import Test 4 | 5 | from . import Resource 6 | 7 | 8 | class TestsHandpick(Resource): 9 | 10 | async def get(self, request): 11 | tests = (Test.objects(status=Test.STATUS_PUBLISHED, is_digest=True) 12 | .order_by('-participate_number') 13 | .skip(0).limit(10)) 14 | return tests, 200 15 | -------------------------------------------------------------------------------- /apis/v1/api/tests_id.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from apis.models.test import Test 4 | from apis.exception import NotFound 5 | 6 | from . import Resource 7 | 8 | 9 | class TestsId(Resource): 10 | 11 | async def get(self, request, id): 12 | test = Test.objects(id=id).first() 13 | if not test or test.status != Test.STATUS_PUBLISHED: 14 | raise NotFound('test_not_found') 15 | return test, 200 16 | -------------------------------------------------------------------------------- /apis/v1/api/tests_id_answers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from datetime import datetime 4 | 5 | from apis.models import generation_objectid 6 | from apis.models.test import Test, Question, Answer 7 | from apis.exception import NotFound 8 | from apis.verification import current_account 9 | 10 | 11 | from . import Resource 12 | 13 | 14 | class TestsIdAnswers(Resource): 15 | 16 | def calculate_score(self, test, answers): 17 | questions = Question.objects(test_id=test.id).order_by('number').all() 18 | correct_answers = {} 19 | for question in questions: 20 | for option in question.options: 21 | if option.get('is_checked'): 22 | correct_answers.setdefault(question.id, []).append(option['index']) 23 | corrent_count = 0 24 | for qid, answer in answers.items(): 25 | if correct_answers.get(qid) == answer: 26 | corrent_count += 1 27 | score = test.total_score / test.question_count * corrent_count 28 | return score 29 | 30 | async def post(self, request, id): 31 | test = Test.objects(id=id).first() 32 | if not test or test.status != Test.STATUS_PUBLISHED: 33 | raise NotFound('test_not_found') 34 | question_id = request.json['question_id'] 35 | options = request.json['options'] 36 | question = Question.objects(test_id=id, id=question_id).first() 37 | if not question: 38 | raise NotFound('question_not_found') 39 | answer = Answer.objects(account_id=current_account.id, 40 | test_id=test.id).first() 41 | if not answer: 42 | Answer( 43 | id=generation_objectid(), 44 | account_id=current_account.id, 45 | test_id=test.id, 46 | answers={question_id: options}, 47 | updated_time=datetime.utcnow()).save() 48 | else: 49 | answers = answer.answers 50 | answers[question_id] = options 51 | answer.update(answers=answers, updated_time=datetime.utcnow()) 52 | answer.save() 53 | if question.number == test.question_count - 1: 54 | last = True 55 | score = self.calculate_score(test, answer.answers) 56 | answer.update(score=score, status=Answer.STATUS_FINISHED) 57 | # 统计分数 58 | else: 59 | last = False 60 | return {'ok': True, 'last': last}, 201 61 | -------------------------------------------------------------------------------- /apis/v1/api/tests_id_questions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | from apis.models.test import Test, Question 5 | from apis.exception import NotFound 6 | 7 | from . import Resource 8 | 9 | 10 | class TestsIdQuestions(Resource): 11 | 12 | def _get_test(self, id): 13 | test = Test.objects(id=id).first() 14 | if not test: 15 | raise NotFound('account_not_found') 16 | return test 17 | 18 | async def get(self, request, id): 19 | test = self._get_test(id) 20 | questions = (Question 21 | .objects(test_id=test.id) 22 | .order_by('number') 23 | .all()) 24 | return questions, 200 25 | -------------------------------------------------------------------------------- /apis/v1/api/tests_id_score.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from apis.models.test import Test, Answer 4 | from apis.exception import NotFound 5 | 6 | from . import Resource 7 | 8 | 9 | class TestsIdScore(Resource): 10 | 11 | def _get_test(self, id): 12 | test = Test.objects(id=id).first() 13 | if not test or test.status == 'draft': 14 | raise NotFound('account_not_found') 15 | return test 16 | 17 | async def get(self, request, id): 18 | answer = Answer.objects(test_id=id).first() 19 | if not answer: 20 | raise NotFound('answer_not_found') 21 | return answer, 200 22 | -------------------------------------------------------------------------------- /apis/v1/api/tests_id_statistics.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from sanic.response import text 4 | 5 | from . import Resource 6 | from .. import schemas 7 | 8 | 9 | class TestsIdStatistics(Resource): 10 | 11 | async def get(self, request, id): 12 | print(request.headers) 13 | 14 | return {}, 200, None -------------------------------------------------------------------------------- /apis/v1/routes.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | ### 4 | ### DO NOT CHANGE THIS FILE 5 | ### 6 | ### The code is auto generated, your change will be overwritten by 7 | ### code generating. 8 | ### 9 | from __future__ import absolute_import 10 | 11 | from .api.self_tests import SelfTests 12 | from .api.self_tests_id import SelfTestsId 13 | from .api.self_tests_id_publish import SelfTestsIdPublish 14 | from .api.self_tests_id_questions import SelfTestsIdQuestions 15 | from .api.self_tests_test_id_questions_id import SelfTestsTestIdQuestionsId 16 | from .api.self_testings import SelfTestings 17 | from .api.tests_banner import TestsBanner 18 | from .api.tests_handpick import TestsHandpick 19 | from .api.tests_id import TestsId 20 | from .api.tests_id_questions import TestsIdQuestions 21 | from .api.tests_id_score import TestsIdScore 22 | from .api.tests_id_answers import TestsIdAnswers 23 | from .api.tests_id_statistics import TestsIdStatistics 24 | from .api.qc_cos_config import QcCosConfig 25 | 26 | 27 | routes = [ 28 | dict(resource=SelfTests.as_view(), urls=['/self/tests'], endpoint='self_tests'), 29 | dict(resource=SelfTestsId.as_view(), urls=['/self/tests/'], endpoint='self_tests_id'), 30 | dict(resource=SelfTestsIdPublish.as_view(), urls=['/self/tests//publish'], endpoint='self_tests_id_publish'), 31 | dict(resource=SelfTestsIdQuestions.as_view(), urls=['/self/tests//questions'], endpoint='self_tests_id_questions'), 32 | dict(resource=SelfTestsTestIdQuestionsId.as_view(), urls=['/self/tests//questions/'], endpoint='self_tests_test_id_questions_id'), 33 | dict(resource=SelfTestings.as_view(), urls=['/self/testings'], endpoint='self_testings'), 34 | dict(resource=TestsBanner.as_view(), urls=['/tests/banner'], endpoint='tests_banner'), 35 | dict(resource=TestsHandpick.as_view(), urls=['/tests/handpick'], endpoint='tests_handpick'), 36 | dict(resource=TestsId.as_view(), urls=['/tests/'], endpoint='tests_id'), 37 | dict(resource=TestsIdQuestions.as_view(), urls=['/tests//questions'], endpoint='tests_id_questions'), 38 | dict(resource=TestsIdScore.as_view(), urls=['/tests//score'], endpoint='tests_id_score'), 39 | dict(resource=TestsIdAnswers.as_view(), urls=['/tests//answers'], endpoint='tests_id_answers'), 40 | dict(resource=TestsIdStatistics.as_view(), urls=['/tests//statistics'], endpoint='tests_id_statistics'), 41 | dict(resource=QcCosConfig.as_view(), urls=['/qc_cos/config'], endpoint='qc_cos_config'), 42 | ] -------------------------------------------------------------------------------- /apis/v1/schemas.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # TODO: datetime support 4 | 5 | ### 6 | ### DO NOT CHANGE THIS FILE 7 | ### 8 | ### The code is auto generated, your change will be overwritten by 9 | ### code generating. 10 | ### 11 | 12 | base_path = '/v1' 13 | 14 | 15 | DefinitionsSuccess = {'properties': {'ok': {'type': 'boolean'}}} 16 | DefinitionsNone = {'type': 'object'} 17 | DefinitionsQcosconfig = {'properties': {'sign': {'type': 'string'}}} 18 | DefinitionsOptionwithanswer = {'required': ['option', 'is_checked'], 'properties': {'index': {'type': 'integer', 'format': 'int32'}, 'option': {'type': 'string'}, 'is_checked': {'type': 'boolean'}}} 19 | DefinitionsDistribution = {'properties': {'value': {'type': 'string'}, 'count': {'type': 'integer', 'format': 'int32'}}} 20 | DefinitionsError = {'properties': {'error_code': {'type': 'integer', 'format': 'int32'}, 'message': {'type': 'string'}, 'text': {'type': 'string'}}} 21 | DefinitionsOption = {'required': ['option'], 'properties': {'index': {'type': 'integer', 'format': 'int32'}, 'option': {'type': 'string'}}} 22 | DefinitionsDatetime = {'type': 'string', 'format': 'datetime'} 23 | DefinitionsTestscore = {'properties': {'test_id': {'type': 'string'}, 'score': {'type': 'integer'}, 'rank': {'type': 'integer'}}} 24 | DefinitionsAnswerquestion = {'required': ['question_id', 'options'], 'properties': {'question_id': {'type': 'string'}, 'options': {'type': 'array', 'items': {'type': 'integer', 'format': 'int32'}}}} 25 | DefinitionsAnswersuccess = {'properties': {'ok': {'type': 'boolean'}, 'last': {'type': 'boolean'}}} 26 | DefinitionsAccount = {'description': 'account 基本信息', 'required': ['id'], 'properties': {'id': {'type': 'string'}, 'nickname': {'type': 'string'}, 'avatar': {'type': 'string'}}} 27 | DefinitionsAuthentications = {'description': '用户详细授权数据', 'properties': {'wxapp': {'type': 'string'}, 'mobile': {'type': 'string'}}} 28 | DefinitionsTestdetail = {'required': ['id', 'title', 'description'], 'properties': {'id': {'type': 'string'}, 'image': {'type': 'string'}, 'title': {'type': 'string'}, 'description': {'type': 'string'}, 'date_start': {'type': 'string'}, 'date_end': {'type': 'string'}, 'time_start': {'type': 'string'}, 'time_end': {'type': 'string'}, 'status': {'type': 'string'}, 'start_time': {'type': 'string', 'format': 'datetime'}, 'end_time': {'type': 'string', 'format': 'datetime'}, 'remark': {'type': 'string'}, 'question_count': {'type': 'integer'}, 'creator': {'description': 'account 基本信息', 'required': ['id'], 'properties': {'id': {'type': 'string'}, 'nickname': {'type': 'string'}, 'avatar': {'type': 'string'}}}, 'created_time': {'type': 'string', 'format': 'datetime'}}} 29 | DefinitionsCreatequestion = {'properties': {'title': {'type': 'string'}, 'type': {'type': 'string', 'enum': ['single_choice', 'multiple_choice']}, 'options': {'type': 'array', 'items': {'required': ['option', 'is_checked'], 'properties': {'index': {'type': 'integer', 'format': 'int32'}, 'option': {'type': 'string'}, 'is_checked': {'type': 'boolean'}}}}}} 30 | DefinitionsAccountdetail = {'description': 'account 信息', 'required': ['id'], 'properties': {'id': {'type': 'string'}, 'username': {'type': 'string'}, 'nickname': {'type': 'string'}, 'avatar': {'type': 'string'}, 'authentications': {'description': '用户详细授权数据', 'properties': {'wxapp': {'type': 'string'}, 'mobile': {'type': 'string'}}}, 'created_time': {'type': 'string', 'format': 'datetime'}}} 31 | DefinitionsTest = {'required': ['id', 'title', 'description'], 'properties': {'id': {'type': 'string'}, 'image': {'type': 'string'}, 'title': {'type': 'string'}, 'description': {'type': 'string'}, 'start_time': {'type': 'string', 'format': 'datetime'}, 'end_time': {'type': 'string', 'format': 'datetime'}, 'question_count': {'type': 'integer'}, 'creator': {'description': 'account 基本信息', 'required': ['id'], 'properties': {'id': {'type': 'string'}, 'nickname': {'type': 'string'}, 'avatar': {'type': 'string'}}}, 'created_time': {'type': 'string', 'format': 'datetime'}}} 32 | DefinitionsTesting = {'required': ['id', 'title', 'description'], 'properties': {'id': {'type': 'string'}, 'score': {'type': 'integer', 'format': 'int32'}, 'image': {'type': 'string'}, 'title': {'type': 'string'}, 'description': {'type': 'string'}, 'question_count': {'type': 'integer'}, 'creator': {'description': 'account 基本信息', 'required': ['id'], 'properties': {'id': {'type': 'string'}, 'nickname': {'type': 'string'}, 'avatar': {'type': 'string'}}}, 'created_time': {'type': 'string', 'format': 'datetime'}}} 33 | DefinitionsUpdatequestion = {'properties': {'title': {'type': 'string'}, 'type': {'type': 'string', 'enum': ['single_choice', 'multiple_choice']}, 'options': {'type': 'array', 'items': {'required': ['option', 'is_checked'], 'properties': {'index': {'type': 'integer', 'format': 'int32'}, 'option': {'type': 'string'}, 'is_checked': {'type': 'boolean'}}}}}} 34 | DefinitionsCreatetest = {'required': ['title', 'description'], 'properties': {'image': {'type': 'string'}, 'title': {'type': 'string', 'minLength': 3, 'maxLength': 128}, 'description': {'type': 'string', 'minLength': 6, 'maxLength': 256}, 'start_time': {'type': 'string', 'format': 'datetime'}, 'end_time': {'type': 'string', 'format': 'datetime'}, 'remark': {'type': 'string'}}} 35 | DefinitionsUpdatetest = {'properties': {'image': {'type': 'string'}, 'title': {'type': 'string', 'minLength': 3, 'maxLength': 128}, 'description': {'type': 'string', 'minLength': 6, 'maxLength': 256}, 'remark': {'type': 'string'}, 'start_time': {'type': 'string', 'format': 'datetime'}, 'end_time': {'type': 'string', 'format': 'datetime'}}} 36 | DefinitionsQuestiondetail = {'required': ['id', 'title', 'options'], 'properties': {'id': {'type': 'string'}, 'title': {'type': 'string'}, 'type': {'type': 'string', 'enum': ['single_choice', 'multiple_choice']}, 'number': {'type': 'integer', 'format': 'int32'}, 'options': {'type': 'array', 'items': {'required': ['option', 'is_checked'], 'properties': {'index': {'type': 'integer', 'format': 'int32'}, 'option': {'type': 'string'}, 'is_checked': {'type': 'boolean'}}}}}} 37 | DefinitionsTestingstatistics = {'properties': {'total_count': {'type': 'integer'}, 'max_score': {'type': 'integer'}, 'min_score': {'type': 'integer'}, 'avg_score': {'type': 'integer'}, 'distributions': {'type': 'array', 'items': {'properties': {'value': {'type': 'string'}, 'count': {'type': 'integer', 'format': 'int32'}}}}}} 38 | DefinitionsQuestion = {'required': ['id', 'title', 'options'], 'properties': {'id': {'type': 'string'}, 'title': {'type': 'string'}, 'number': {'type': 'integer', 'format': 'int32'}, 'options': {'type': 'array', 'items': {'required': ['option'], 'properties': {'index': {'type': 'integer', 'format': 'int32'}, 'option': {'type': 'string'}}}}}} 39 | 40 | validators = { 41 | ('self_tests', 'GET'): {'headers': {'required': ['Authorization'], 'properties': {'Authorization': {'type': 'string'}}}, 'args': {'required': [], 'properties': {'status': {'description': 'test status in query', 'type': 'string', 'required': False, 'enum': ['draft', 'published', 'withdraw']}, 'offset': {'description': 'offset in query', 'type': 'integer', 'format': 'int32', 'default': 0, 'required': False}, 'limit': {'description': 'limit in query', 'type': 'integer', 'format': 'int32', 'default': 20, 'required': False}}}}, 42 | ('self_tests', 'POST'): {'json': {'required': ['title', 'description'], 'properties': {'image': {'type': 'string'}, 'title': {'type': 'string', 'minLength': 3, 'maxLength': 128}, 'description': {'type': 'string', 'minLength': 6, 'maxLength': 256}, 'start_time': {'type': 'string', 'format': 'datetime'}, 'end_time': {'type': 'string', 'format': 'datetime'}, 'remark': {'type': 'string'}}}, 'headers': {'required': ['Authorization'], 'properties': {'Authorization': {'type': 'string'}}}}, 43 | ('self_tests_id', 'GET'): {'headers': {'required': ['Authorization'], 'properties': {'Authorization': {'type': 'string'}}}}, 44 | ('self_tests_id', 'PUT'): {'json': {'properties': {'image': {'type': 'string'}, 'title': {'type': 'string', 'minLength': 3, 'maxLength': 128}, 'description': {'type': 'string', 'minLength': 6, 'maxLength': 256}, 'remark': {'type': 'string'}, 'start_time': {'type': 'string', 'format': 'datetime'}, 'end_time': {'type': 'string', 'format': 'datetime'}}}, 'headers': {'required': ['Authorization'], 'properties': {'Authorization': {'type': 'string'}}}}, 45 | ('self_tests_id', 'DELETE'): {'headers': {'required': ['Authorization'], 'properties': {'Authorization': {'type': 'string'}}}}, 46 | ('self_tests_id_publish', 'PUT'): {'headers': {'required': ['Authorization'], 'properties': {'Authorization': {'type': 'string'}}}}, 47 | ('self_tests_id_publish', 'DELETE'): {'headers': {'required': ['Authorization'], 'properties': {'Authorization': {'type': 'string'}}}}, 48 | ('self_tests_id_questions', 'GET'): {'headers': {'required': ['Authorization'], 'properties': {'Authorization': {'type': 'string'}}}, 'args': {'required': [], 'properties': {'step': {'description': 'step in query', 'type': 'integer', 'format': 'int32', 'default': 0}}}}, 49 | ('self_tests_id_questions', 'POST'): {'json': {'properties': {'title': {'type': 'string'}, 'type': {'type': 'string', 'enum': ['single_choice', 'multiple_choice']}, 'options': {'type': 'array', 'items': {'required': ['option', 'is_checked'], 'properties': {'index': {'type': 'integer', 'format': 'int32'}, 'option': {'type': 'string'}, 'is_checked': {'type': 'boolean'}}}}}}, 'headers': {'required': ['Authorization'], 'properties': {'Authorization': {'type': 'string'}}}}, 50 | ('self_tests_test_id_questions_id', 'PUT'): {'json': {'properties': {'title': {'type': 'string'}, 'type': {'type': 'string', 'enum': ['single_choice', 'multiple_choice']}, 'options': {'type': 'array', 'items': {'required': ['option', 'is_checked'], 'properties': {'index': {'type': 'integer', 'format': 'int32'}, 'option': {'type': 'string'}, 'is_checked': {'type': 'boolean'}}}}}}, 'headers': {'required': ['Authorization'], 'properties': {'Authorization': {'type': 'string'}}}}, 51 | ('self_testings', 'GET'): {'headers': {'required': ['Authorization'], 'properties': {'Authorization': {'type': 'string'}}}, 'args': {'required': [], 'properties': {'offset': {'description': 'offset in query', 'type': 'integer', 'format': 'int32', 'default': 0, 'required': False}, 'limit': {'description': 'limit in query', 'type': 'integer', 'format': 'int32', 'default': 20, 'required': False}}}}, 52 | ('tests_banner', 'GET'): {'headers': {'required': ['Authorization'], 'properties': {'Authorization': {'type': 'string'}}}}, 53 | ('tests_handpick', 'GET'): {'headers': {'required': ['Authorization'], 'properties': {'Authorization': {'type': 'string'}}}}, 54 | ('tests_id', 'GET'): {'headers': {'required': ['Authorization'], 'properties': {'Authorization': {'type': 'string'}}}}, 55 | ('tests_id_questions', 'GET'): {'headers': {'required': ['Authorization'], 'properties': {'Authorization': {'type': 'string'}}}}, 56 | ('tests_id_score', 'GET'): {'headers': {'required': ['Authorization'], 'properties': {'Authorization': {'type': 'string'}}}}, 57 | ('tests_id_answers', 'POST'): {'json': {'required': ['question_id', 'options'], 'properties': {'question_id': {'type': 'string'}, 'options': {'type': 'array', 'items': {'type': 'integer', 'format': 'int32'}}}}, 'headers': {'required': ['Authorization'], 'properties': {'Authorization': {'type': 'string'}}}}, 58 | ('tests_id_statistics', 'GET'): {'headers': {'required': ['Authorization'], 'properties': {'Authorization': {'type': 'string'}}}}, 59 | ('qc_cos_config', 'GET'): {'headers': {'required': ['Authorization'], 'properties': {'Authorization': {'type': 'string'}}}, 'args': {'required': [], 'properties': {'cos_path': {'description': 'cos path', 'type': 'string', 'required': False}}}}, 60 | } 61 | 62 | filters = { 63 | ('self_tests', 'GET'): {200: {'headers': None, 'schema': {'type': 'array', 'items': {'required': ['id', 'title', 'description'], 'properties': {'id': {'type': 'string'}, 'image': {'type': 'string'}, 'title': {'type': 'string'}, 'description': {'type': 'string'}, 'date_start': {'type': 'string'}, 'date_end': {'type': 'string'}, 'time_start': {'type': 'string'}, 'time_end': {'type': 'string'}, 'status': {'type': 'string'}, 'start_time': {'type': 'string', 'format': 'datetime'}, 'end_time': {'type': 'string', 'format': 'datetime'}, 'remark': {'type': 'string'}, 'question_count': {'type': 'integer'}, 'creator': {'description': 'account 基本信息', 'required': ['id'], 'properties': {'id': {'type': 'string'}, 'nickname': {'type': 'string'}, 'avatar': {'type': 'string'}}}, 'created_time': {'type': 'string', 'format': 'datetime'}}}}}}, 64 | ('self_tests', 'POST'): {201: {'headers': None, 'schema': {'required': ['id', 'title', 'description'], 'properties': {'id': {'type': 'string'}, 'image': {'type': 'string'}, 'title': {'type': 'string'}, 'description': {'type': 'string'}, 'date_start': {'type': 'string'}, 'date_end': {'type': 'string'}, 'time_start': {'type': 'string'}, 'time_end': {'type': 'string'}, 'status': {'type': 'string'}, 'start_time': {'type': 'string', 'format': 'datetime'}, 'end_time': {'type': 'string', 'format': 'datetime'}, 'remark': {'type': 'string'}, 'question_count': {'type': 'integer'}, 'creator': {'description': 'account 基本信息', 'required': ['id'], 'properties': {'id': {'type': 'string'}, 'nickname': {'type': 'string'}, 'avatar': {'type': 'string'}}}, 'created_time': {'type': 'string', 'format': 'datetime'}}}}}, 65 | ('self_tests_id', 'GET'): {200: {'headers': None, 'schema': {'required': ['id', 'title', 'description'], 'properties': {'id': {'type': 'string'}, 'image': {'type': 'string'}, 'title': {'type': 'string'}, 'description': {'type': 'string'}, 'date_start': {'type': 'string'}, 'date_end': {'type': 'string'}, 'time_start': {'type': 'string'}, 'time_end': {'type': 'string'}, 'status': {'type': 'string'}, 'start_time': {'type': 'string', 'format': 'datetime'}, 'end_time': {'type': 'string', 'format': 'datetime'}, 'remark': {'type': 'string'}, 'question_count': {'type': 'integer'}, 'creator': {'description': 'account 基本信息', 'required': ['id'], 'properties': {'id': {'type': 'string'}, 'nickname': {'type': 'string'}, 'avatar': {'type': 'string'}}}, 'created_time': {'type': 'string', 'format': 'datetime'}}}}}, 66 | ('self_tests_id', 'PUT'): {200: {'headers': None, 'schema': {'required': ['id', 'title', 'description'], 'properties': {'id': {'type': 'string'}, 'image': {'type': 'string'}, 'title': {'type': 'string'}, 'description': {'type': 'string'}, 'date_start': {'type': 'string'}, 'date_end': {'type': 'string'}, 'time_start': {'type': 'string'}, 'time_end': {'type': 'string'}, 'status': {'type': 'string'}, 'start_time': {'type': 'string', 'format': 'datetime'}, 'end_time': {'type': 'string', 'format': 'datetime'}, 'remark': {'type': 'string'}, 'question_count': {'type': 'integer'}, 'creator': {'description': 'account 基本信息', 'required': ['id'], 'properties': {'id': {'type': 'string'}, 'nickname': {'type': 'string'}, 'avatar': {'type': 'string'}}}, 'created_time': {'type': 'string', 'format': 'datetime'}}}}}, 67 | ('self_tests_id', 'DELETE'): {204: {'headers': None, 'schema': {'properties': {'ok': {'type': 'boolean'}}}}}, 68 | ('self_tests_id_publish', 'PUT'): {200: {'headers': None, 'schema': {'required': ['id', 'title', 'description'], 'properties': {'id': {'type': 'string'}, 'image': {'type': 'string'}, 'title': {'type': 'string'}, 'description': {'type': 'string'}, 'date_start': {'type': 'string'}, 'date_end': {'type': 'string'}, 'time_start': {'type': 'string'}, 'time_end': {'type': 'string'}, 'status': {'type': 'string'}, 'start_time': {'type': 'string', 'format': 'datetime'}, 'end_time': {'type': 'string', 'format': 'datetime'}, 'remark': {'type': 'string'}, 'question_count': {'type': 'integer'}, 'creator': {'description': 'account 基本信息', 'required': ['id'], 'properties': {'id': {'type': 'string'}, 'nickname': {'type': 'string'}, 'avatar': {'type': 'string'}}}, 'created_time': {'type': 'string', 'format': 'datetime'}}}}}, 69 | ('self_tests_id_publish', 'DELETE'): {204: {'headers': None, 'schema': {'properties': {'ok': {'type': 'boolean'}}}}}, 70 | ('self_tests_id_questions', 'GET'): {200: {'headers': None, 'schema': {'type': 'array', 'items': {'required': ['id', 'title', 'options'], 'properties': {'id': {'type': 'string'}, 'title': {'type': 'string'}, 'type': {'type': 'string', 'enum': ['single_choice', 'multiple_choice']}, 'number': {'type': 'integer', 'format': 'int32'}, 'options': {'type': 'array', 'items': {'required': ['option', 'is_checked'], 'properties': {'index': {'type': 'integer', 'format': 'int32'}, 'option': {'type': 'string'}, 'is_checked': {'type': 'boolean'}}}}}}}}}, 71 | ('self_tests_id_questions', 'POST'): {201: {'headers': None, 'schema': {'required': ['id', 'title', 'options'], 'properties': {'id': {'type': 'string'}, 'title': {'type': 'string'}, 'type': {'type': 'string', 'enum': ['single_choice', 'multiple_choice']}, 'number': {'type': 'integer', 'format': 'int32'}, 'options': {'type': 'array', 'items': {'required': ['option', 'is_checked'], 'properties': {'index': {'type': 'integer', 'format': 'int32'}, 'option': {'type': 'string'}, 'is_checked': {'type': 'boolean'}}}}}}}}, 72 | ('self_tests_test_id_questions_id', 'PUT'): {200: {'headers': None, 'schema': {'required': ['id', 'title', 'options'], 'properties': {'id': {'type': 'string'}, 'title': {'type': 'string'}, 'number': {'type': 'integer', 'format': 'int32'}, 'options': {'type': 'array', 'items': {'required': ['option'], 'properties': {'index': {'type': 'integer', 'format': 'int32'}, 'option': {'type': 'string'}}}}}}}}, 73 | ('self_testings', 'GET'): {200: {'headers': None, 'schema': {'type': 'array', 'items': {'required': ['id', 'title', 'description'], 'properties': {'id': {'type': 'string'}, 'score': {'type': 'integer', 'format': 'int32'}, 'image': {'type': 'string'}, 'title': {'type': 'string'}, 'description': {'type': 'string'}, 'question_count': {'type': 'integer'}, 'creator': {'description': 'account 基本信息', 'required': ['id'], 'properties': {'id': {'type': 'string'}, 'nickname': {'type': 'string'}, 'avatar': {'type': 'string'}}}, 'created_time': {'type': 'string', 'format': 'datetime'}}}}}}, 74 | ('tests_banner', 'GET'): {200: {'headers': None, 'schema': {'type': 'array', 'items': {'required': ['id', 'title', 'description'], 'properties': {'id': {'type': 'string'}, 'image': {'type': 'string'}, 'title': {'type': 'string'}, 'description': {'type': 'string'}, 'start_time': {'type': 'string', 'format': 'datetime'}, 'end_time': {'type': 'string', 'format': 'datetime'}, 'question_count': {'type': 'integer'}, 'creator': {'description': 'account 基本信息', 'required': ['id'], 'properties': {'id': {'type': 'string'}, 'nickname': {'type': 'string'}, 'avatar': {'type': 'string'}}}, 'created_time': {'type': 'string', 'format': 'datetime'}}}}}}, 75 | ('tests_handpick', 'GET'): {200: {'headers': None, 'schema': {'type': 'array', 'items': {'required': ['id', 'title', 'description'], 'properties': {'id': {'type': 'string'}, 'image': {'type': 'string'}, 'title': {'type': 'string'}, 'description': {'type': 'string'}, 'start_time': {'type': 'string', 'format': 'datetime'}, 'end_time': {'type': 'string', 'format': 'datetime'}, 'question_count': {'type': 'integer'}, 'creator': {'description': 'account 基本信息', 'required': ['id'], 'properties': {'id': {'type': 'string'}, 'nickname': {'type': 'string'}, 'avatar': {'type': 'string'}}}, 'created_time': {'type': 'string', 'format': 'datetime'}}}}}}, 76 | ('tests_id', 'GET'): {200: {'headers': None, 'schema': {'required': ['id', 'title', 'description'], 'properties': {'id': {'type': 'string'}, 'image': {'type': 'string'}, 'title': {'type': 'string'}, 'description': {'type': 'string'}, 'start_time': {'type': 'string', 'format': 'datetime'}, 'end_time': {'type': 'string', 'format': 'datetime'}, 'question_count': {'type': 'integer'}, 'creator': {'description': 'account 基本信息', 'required': ['id'], 'properties': {'id': {'type': 'string'}, 'nickname': {'type': 'string'}, 'avatar': {'type': 'string'}}}, 'created_time': {'type': 'string', 'format': 'datetime'}}}}}, 77 | ('tests_id_questions', 'GET'): {200: {'headers': None, 'schema': {'type': 'array', 'items': {'required': ['id', 'title', 'options'], 'properties': {'id': {'type': 'string'}, 'title': {'type': 'string'}, 'number': {'type': 'integer', 'format': 'int32'}, 'options': {'type': 'array', 'items': {'required': ['option'], 'properties': {'index': {'type': 'integer', 'format': 'int32'}, 'option': {'type': 'string'}}}}}}}}}, 78 | ('tests_id_score', 'GET'): {200: {'headers': None, 'schema': {'properties': {'test_id': {'type': 'string'}, 'score': {'type': 'integer'}, 'rank': {'type': 'integer'}}}}}, 79 | ('tests_id_answers', 'POST'): {201: {'headers': None, 'schema': {'properties': {'ok': {'type': 'boolean'}, 'last': {'type': 'boolean'}}}}}, 80 | ('tests_id_statistics', 'GET'): {200: {'headers': None, 'schema': {'properties': {'total_count': {'type': 'integer'}, 'max_score': {'type': 'integer'}, 'min_score': {'type': 'integer'}, 'avg_score': {'type': 'integer'}, 'distributions': {'type': 'array', 'items': {'properties': {'value': {'type': 'string'}, 'count': {'type': 'integer', 'format': 'int32'}}}}}}}}, 81 | ('qc_cos_config', 'GET'): {200: {'headers': None, 'schema': {'properties': {'sign': {'type': 'string'}}}}}, 82 | } 83 | 84 | scopes = { 85 | ('self_tests', 'GET'): ['open'], 86 | ('self_tests', 'POST'): ['open'], 87 | ('self_tests_id', 'GET'): ['open'], 88 | ('self_tests_id', 'PUT'): ['open'], 89 | ('self_tests_id', 'DELETE'): ['open'], 90 | ('self_tests_id_publish', 'PUT'): ['open'], 91 | ('self_tests_id_publish', 'DELETE'): ['open'], 92 | ('self_tests_id_questions', 'GET'): ['open'], 93 | ('self_tests_id_questions', 'POST'): ['open'], 94 | ('self_tests_test_id_questions_id', 'PUT'): ['open'], 95 | ('self_testings', 'GET'): ['open'], 96 | ('tests_banner', 'GET'): ['open'], 97 | ('tests_handpick', 'GET'): ['open'], 98 | ('tests_id', 'GET'): ['open'], 99 | ('tests_id_questions', 'GET'): ['open'], 100 | ('tests_id_score', 'GET'): ['open'], 101 | ('tests_id_answers', 'POST'): ['open'], 102 | ('tests_id_statistics', 'GET'): ['open'], 103 | ('qc_cos_config', 'GET'): ['open'], 104 | } 105 | 106 | 107 | class Current(object): 108 | 109 | request = None 110 | 111 | 112 | current = Current() 113 | 114 | 115 | class Security(object): 116 | 117 | def __init__(self): 118 | super(Security, self).__init__() 119 | self._loader = lambda x: [] 120 | 121 | @property 122 | def scopes(self): 123 | return self._loader(current.request) 124 | 125 | def scopes_loader(self, func): 126 | self._loader = func 127 | return func 128 | 129 | security = Security() 130 | 131 | 132 | def merge_default(schema, value, get_first=True): 133 | # TODO: more types support 134 | type_defaults = { 135 | 'integer': 9573, 136 | 'string': 'something', 137 | 'object': {}, 138 | 'array': [], 139 | 'boolean': False 140 | } 141 | 142 | results = normalize(schema, value, type_defaults) 143 | if get_first: 144 | return results[0] 145 | return results 146 | 147 | 148 | def normalize(schema, data, required_defaults=None): 149 | if required_defaults is None: 150 | required_defaults = {} 151 | errors = [] 152 | 153 | class DataWrapper(object): 154 | 155 | def __init__(self, data): 156 | super(DataWrapper, self).__init__() 157 | self.data = data 158 | 159 | def get(self, key, default=None): 160 | if isinstance(self.data, dict): 161 | return self.data.get(key, default) 162 | return getattr(self.data, key, default) 163 | 164 | def has(self, key): 165 | if isinstance(self.data, dict): 166 | return key in self.data 167 | return hasattr(self.data, key) 168 | 169 | def keys(self): 170 | if isinstance(self.data, dict): 171 | return list(self.data.keys()) 172 | return list(getattr(self.data, '__dict__', {}).keys()) 173 | 174 | def get_check(self, key, default=None): 175 | if isinstance(self.data, dict): 176 | value = self.data.get(key, default) 177 | has_key = key in self.data 178 | else: 179 | try: 180 | value = getattr(self.data, key) 181 | except AttributeError: 182 | value = default 183 | has_key = False 184 | else: 185 | has_key = True 186 | return value, has_key 187 | 188 | def _merge_dict(src, dst): 189 | for k, v in six.iteritems(dst): 190 | if isinstance(src, dict): 191 | if isinstance(v, dict): 192 | r = _merge_dict(src.get(k, {}), v) 193 | src[k] = r 194 | else: 195 | src[k] = v 196 | else: 197 | src = {k: v} 198 | return src 199 | 200 | def _normalize_dict(schema, data): 201 | result = {} 202 | if not isinstance(data, DataWrapper): 203 | data = DataWrapper(data) 204 | 205 | for _schema in schema.get('allOf', []): 206 | rs_component = _normalize(_schema, data) 207 | _merge_dict(result, rs_component) 208 | 209 | for key, _schema in six.iteritems(schema.get('properties', {})): 210 | # set default 211 | type_ = _schema.get('type', 'object') 212 | 213 | # get value 214 | value, has_key = data.get_check(key) 215 | if has_key: 216 | result[key] = _normalize(_schema, value) 217 | elif 'default' in _schema: 218 | result[key] = _schema['default'] 219 | elif key in schema.get('required', []): 220 | if type_ in required_defaults: 221 | result[key] = required_defaults[type_] 222 | else: 223 | errors.append(dict(name='property_missing', 224 | message='`%s` is required' % key)) 225 | 226 | additional_properties_schema = schema.get('additionalProperties', False) 227 | if additional_properties_schema is not False: 228 | aproperties_set = set(data.keys()) - set(result.keys()) 229 | for pro in aproperties_set: 230 | result[pro] = _normalize(additional_properties_schema, data.get(pro)) 231 | 232 | return result 233 | 234 | def _normalize_list(schema, data): 235 | result = [] 236 | if hasattr(data, '__iter__') and not isinstance(data, dict): 237 | for item in data: 238 | result.append(_normalize(schema.get('items'), item)) 239 | elif 'default' in schema: 240 | result = schema['default'] 241 | return result 242 | 243 | def _normalize_default(schema, data): 244 | if data is None: 245 | return schema.get('default') 246 | else: 247 | return data 248 | 249 | def _normalize(schema, data): 250 | if schema is True or schema == {}: 251 | return data 252 | if not schema: 253 | return None 254 | funcs = { 255 | 'object': _normalize_dict, 256 | 'array': _normalize_list, 257 | 'default': _normalize_default, 258 | } 259 | type_ = schema.get('type', 'object') 260 | if type_ not in funcs: 261 | type_ = 'default' 262 | 263 | return funcs[type_](schema, data) 264 | 265 | return _normalize(schema, data), errors 266 | 267 | -------------------------------------------------------------------------------- /apis/v1/validators.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | ### 3 | ### DO NOT CHANGE THIS FILE 4 | ### 5 | ### The code is auto generated, your change will be overwritten by 6 | ### code generating. 7 | ### 8 | from __future__ import absolute_import, print_function 9 | 10 | import re 11 | import json 12 | from datetime import date 13 | from functools import wraps 14 | 15 | import six 16 | from sanic import response 17 | from sanic.exceptions import ServerError 18 | from sanic.response import HTTPResponse 19 | 20 | from werkzeug.datastructures import MultiDict, Headers 21 | from sanic.request import RequestParameters 22 | from jsonschema import Draft4Validator 23 | 24 | from .schemas import ( 25 | validators, filters, scopes, security, base_path, normalize, current) 26 | 27 | 28 | def unpack(value): 29 | """Return a three tuple of data, code, and headers""" 30 | if not isinstance(value, tuple): 31 | return value, 200, {} 32 | 33 | try: 34 | data, code, headers = value 35 | return data, code, headers 36 | except ValueError: 37 | pass 38 | 39 | try: 40 | data, code = value 41 | return data, code, {} 42 | except ValueError: 43 | pass 44 | 45 | return value, 200, {} 46 | 47 | 48 | def _remove_characters(text, deletechars): 49 | return text.translate({ord(x): None for x in deletechars}) 50 | 51 | 52 | def _path_to_endpoint(path): 53 | endpoint = '_'.join(filter(None, re.sub(r'(/|<|>|-)', r'_', path).split('_'))) 54 | _base_path = base_path.strip('/').replace('/', '_').replace('-', '_') 55 | if endpoint.startswith(_base_path): 56 | endpoint = endpoint[len(_base_path)+1:] 57 | return _remove_characters(endpoint, '{}') 58 | 59 | 60 | class JSONEncoder(json.JSONEncoder): 61 | 62 | def default(self, o): 63 | if isinstance(o, date): 64 | return o.isoformat() 65 | return json.JSONEncoder.default(self, o) 66 | 67 | 68 | class SanicValidatorAdaptor(object): 69 | 70 | def __init__(self, schema): 71 | self.validator = Draft4Validator(schema) 72 | 73 | def validate_number(self, type_, value): 74 | try: 75 | return type_(value) 76 | except ValueError: 77 | return value 78 | 79 | def type_convert(self, obj): 80 | if obj is None: 81 | return None 82 | if isinstance(obj, (dict, list)) and not isinstance(obj, RequestParameters): 83 | return obj 84 | if isinstance(obj, Headers): 85 | obj = MultiDict(obj.items()) 86 | result = dict() 87 | 88 | convert_funs = { 89 | 'integer': lambda v: self.validate_number(int, v[0]), 90 | 'boolean': lambda v: v[0].lower() not in ['n', 'no', 'false', '', '0'], 91 | 'null': lambda v: None, 92 | 'number': lambda v: self.validate_number(float, v[0]), 93 | 'string': lambda v: v[0] 94 | } 95 | 96 | def convert_array(type_, v): 97 | func = convert_funs.get(type_, lambda v: v[0]) 98 | return [func([i]) for i in v] 99 | 100 | for k, values in obj.items(): 101 | prop = self.validator.schema['properties'].get(k, {}) 102 | type_ = prop.get('type') 103 | fun = convert_funs.get(type_, lambda v: v[0]) 104 | if type_ == 'array': 105 | item_type = prop.get('items', {}).get('type') 106 | result[k] = convert_array(item_type, values) 107 | else: 108 | result[k] = fun(values) 109 | return result 110 | 111 | def validate(self, value): 112 | value = self.type_convert(value) 113 | errors = list(e.message for e in self.validator.iter_errors(value)) 114 | return normalize(self.validator.schema, value)[0], errors 115 | 116 | 117 | def request_validate(view): 118 | 119 | @wraps(view) 120 | def wrapper(*args, **kwargs): 121 | request = args[1] 122 | endpoint = _path_to_endpoint(request.uri_template) 123 | current.request = request 124 | # scope 125 | if (endpoint, request.method) in scopes and not set( 126 | scopes[(endpoint, request.method)]).issubset(set(security.scopes)): 127 | raise ServerError('403', status_code=403) 128 | # data 129 | method = request.method 130 | if method == 'HEAD': 131 | method = 'GET' 132 | locations = validators.get((endpoint, method), {}) 133 | for location, schema in locations.items(): 134 | value = getattr(request, location, MultiDict()) 135 | if value is None: 136 | value = MultiDict() 137 | validator = SanicValidatorAdaptor(schema) 138 | result, errors = validator.validate(value) 139 | if errors: 140 | raise ServerError('Unprocessable Entity', status_code=422) 141 | return view(*args, **kwargs) 142 | 143 | return wrapper 144 | 145 | 146 | def response_filter(view): 147 | 148 | @wraps(view) 149 | async def wrapper(*args, **kwargs): 150 | request = args[1] 151 | resp = view(*args, **kwargs) 152 | 153 | from inspect import isawaitable 154 | if isawaitable(resp): 155 | resp = await resp 156 | if isinstance(resp, HTTPResponse): 157 | return resp 158 | 159 | endpoint = _path_to_endpoint(request.uri_template) 160 | method = request.method 161 | if method == 'HEAD': 162 | method = 'GET' 163 | filter = filters.get((endpoint, method), None) 164 | if not filter: 165 | return resp 166 | 167 | headers = None 168 | status = None 169 | if isinstance(resp, tuple): 170 | resp, status, headers = unpack(resp) 171 | 172 | if len(filter) == 1: 173 | if six.PY3: 174 | status = list(filter.keys())[0] 175 | else: 176 | status = filter.keys()[0] 177 | 178 | schemas = filter.get(status) 179 | if not schemas: 180 | # return resp, status, headers 181 | raise ServerError('`%d` is not a defined status code.' % status, 500) 182 | 183 | resp, errors = normalize(schemas['schema'], resp) 184 | if schemas['headers']: 185 | headers, header_errors = normalize( 186 | {'properties': schemas['headers']}, headers) 187 | errors.extend(header_errors) 188 | if errors: 189 | raise ServerError('Expectation Failed', 500) 190 | 191 | return response.json( 192 | resp, 193 | status=status, 194 | headers=headers, 195 | ) 196 | 197 | return wrapper -------------------------------------------------------------------------------- /apis/verification.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import base64 4 | import time 5 | import jwt 6 | from jwt.exceptions import ExpiredSignatureError 7 | from weixin.helper import smart_str 8 | 9 | from apis.models.oauth import Account, OAuth2Client 10 | from apis.settings import Config 11 | from apis.exception import Unauthorized 12 | from apis.models import ObjectModel 13 | 14 | 15 | class CurrentAccount(object): 16 | 17 | id = None 18 | 19 | 20 | current_account = CurrentAccount() 21 | 22 | 23 | def get_authorization(request): 24 | authorization = request.headers.get('Authorization') 25 | if not authorization: 26 | return False, None 27 | try: 28 | authorization_type, token = authorization.split(' ') 29 | return authorization_type, token 30 | except ValueError: 31 | return False, None 32 | 33 | 34 | def verify_client(client_id, secret): 35 | client = OAuth2Client.objects(client_id=client_id, 36 | secret=secret).first() 37 | if client: 38 | return True, client.scopes or [] 39 | return False, [] 40 | 41 | 42 | def verify_request(request): 43 | authorization_type, token = get_authorization(request) 44 | if authorization_type == 'Basic': 45 | return verify_basic_token(token) 46 | elif authorization_type == 'JWT': 47 | return verify_jwt_token(token) 48 | return False, None 49 | 50 | 51 | def verify_password(username, password): 52 | account = Account.get(username=username, password=password) 53 | if account: 54 | return account 55 | else: 56 | return {} 57 | 58 | 59 | def get_wxapp_userinfo(encrypted_data, iv, code): 60 | from weixin.lib.wxcrypt import WXBizDataCrypt 61 | from weixin import WXAPPAPI 62 | from weixin.oauth2 import OAuth2AuthExchangeError 63 | appid = Config.WXAPP_ID 64 | secret = Config.WXAPP_SECRET 65 | api = WXAPPAPI(appid=appid, app_secret=secret) 66 | try: 67 | session_info = api.exchange_code_for_session_key(code=code) 68 | except OAuth2AuthExchangeError as e: 69 | raise Unauthorized(e.code, e.description) 70 | session_key = session_info.get('session_key') 71 | crypt = WXBizDataCrypt(appid, session_key) 72 | user_info = crypt.decrypt(encrypted_data, iv) 73 | return user_info 74 | 75 | 76 | def verify_wxapp(encrypted_data, iv, code): 77 | user_info = get_wxapp_userinfo(encrypted_data, iv, code) 78 | openid = user_info.get('openId', None) 79 | if openid: 80 | auth = Account.get_by_wxapp(openid) 81 | if not auth: 82 | raise Unauthorized('wxapp_not_registered') 83 | return auth 84 | raise Unauthorized('invalid_wxapp_code') 85 | 86 | 87 | def create_token(request): 88 | # verify basic token 89 | approach = request.json.get('auth_approach') 90 | username = request.json['username'] 91 | password = request.json['password'] 92 | if approach == 'password': 93 | account = verify_password(username, password) 94 | elif approach == 'wxapp': 95 | account = verify_wxapp(username, password, request.args.get('code')) 96 | if not account: 97 | return False, {} 98 | payload = { 99 | "iss": Config.ISS, 100 | "iat": int(time.time()), 101 | "exp": int(time.time()) + 86400 * 7, 102 | "aud": Config.AUDIENCE, 103 | "sub": str(account.id), 104 | "nickname": account.nickname, 105 | "scopes": ['open'] 106 | } 107 | token = jwt.encode(payload, 'secret', algorithm='HS256') 108 | return True, {'access_token': token, 109 | 'nickname': account.nickname, 110 | 'account_id': str(account.id)} 111 | 112 | 113 | def verify_basic_token(token): 114 | try: 115 | client = base64.b64decode(token) 116 | client_id, secret = smart_str(client).split(':') 117 | except (TypeError, ValueError): 118 | return False, None 119 | return verify_client(client_id, secret) 120 | 121 | 122 | def verify_jwt_token(token): 123 | try: 124 | payload = jwt.decode(token, 'secret', 125 | audience=Config.AUDIENCE, 126 | algorithms=['HS256']) 127 | except ExpiredSignatureError: 128 | return False, token 129 | if payload: 130 | return True, ObjectModel.object_from_dictionary(payload) 131 | return False, token 132 | -------------------------------------------------------------------------------- /docs/auth.yml: -------------------------------------------------------------------------------- 1 | swagger: '2.0' 2 | info: 3 | title: Metis Auth API 4 | version: '1.0.0' 5 | schemes: 6 | - http 7 | basePath: /auth 8 | consumes: 9 | - application/json 10 | produces: 11 | - application/json 12 | definitions: 13 | auth_approach: 14 | properties: 15 | auth_approach: 16 | type: string 17 | default: mobile 18 | enum: 19 | - mobile 20 | - wxapp 21 | - weixin 22 | - weixin_mp 23 | description: 登录方式 手机 微信 微信小程序 24 | Authentication: 25 | type: object 26 | description: 获取token 登录 使用 27 | required: 28 | - username 29 | - password 30 | optional: 31 | - grant_type 32 | - auth_approach 33 | allOf: 34 | - $ref: '#/definitions/auth_approach' 35 | - type: object 36 | properties: 37 | username: 38 | type: string 39 | description: 手机号//微信open_id/email 40 | password: 41 | type: string 42 | description: 密码/微信token 43 | grant_type: 44 | type: string 45 | default: password 46 | enum: 47 | - password 48 | description: 认证类型 默认密码 49 | Authentications: 50 | description: 用户详细授权数据 51 | properties: 52 | wxapp: 53 | type: string 54 | mobile: 55 | type: string 56 | TokenDetail: 57 | description: 返回的token信息 58 | required: 59 | - account_id 60 | - access_token 61 | - token_type 62 | properties: 63 | nickname: 64 | type: string 65 | account_id: 66 | type: string 67 | access_token: 68 | type: string 69 | token_type: 70 | type: string 71 | default: jwt 72 | Token: 73 | description: token 74 | properties: 75 | access_token: 76 | type: string 77 | TokenCode: 78 | required: 79 | - code 80 | - grant_type 81 | properties: 82 | code: 83 | type: string 84 | grant_type: 85 | type: string 86 | default: token_code 87 | enum: 88 | - token_code 89 | RefreshToken: 90 | required: 91 | - refresh_token 92 | - grant_type 93 | properties: 94 | refresh_token: 95 | type: string 96 | grant_type: 97 | type: string 98 | default: refresh_token 99 | enum: 100 | - refresh_token 101 | Account: 102 | description: account 基本信息 103 | required: 104 | - id 105 | properties: 106 | id: 107 | type: string 108 | date_created: 109 | format: datetime 110 | type: string 111 | AccountDetail: 112 | description: account 信息 113 | required: 114 | - id 115 | properties: 116 | id: 117 | type: string 118 | username: 119 | type: string 120 | nickname: 121 | type: string 122 | avatar: 123 | type: string 124 | authentications: 125 | $ref: '#/definitions/Authentications' 126 | created_time: 127 | format: datetime 128 | type: string 129 | CreateWXAPPAccount: 130 | required: 131 | - username 132 | - password 133 | - code 134 | properties: 135 | code: 136 | type: string 137 | username: 138 | type: string 139 | password: 140 | type: string 141 | OAuthBind: 142 | description: 绑定登录后绑定第三方帐号 143 | required: 144 | - auth_approach 145 | - identity 146 | - password 147 | properties: 148 | auth_approach: 149 | type: string 150 | default: mobile 151 | enum: 152 | - weibo 153 | - weixin 154 | - wxapp 155 | description: 绑定第三方帐号 微信 微信公众号 微信小程序 156 | password: 157 | type: string 158 | description: 微博token/微信token 159 | identity: 160 | type: string 161 | description: weibo/weixin uid 162 | Password: 163 | required: 164 | - password 165 | properties: 166 | password: 167 | maxLength: 128 168 | minLength: 6 169 | type: string 170 | UpdatePassword: 171 | properties: 172 | new_password: 173 | maxLength: 128 174 | minLength: 6 175 | type: string 176 | password: 177 | maxLength: 128 178 | minLength: 6 179 | type: string 180 | ResetPassword: 181 | properties: 182 | mobile: 183 | type: string 184 | old_password: 185 | maxLength: 128 186 | minLength: 6 187 | type: string 188 | new_password: 189 | maxLength: 128 190 | minLength: 6 191 | type: string 192 | Approach: 193 | required: 194 | - approach 195 | properties: 196 | approach: 197 | type: string 198 | identity: 199 | type: string 200 | is_verified: 201 | type: boolean 202 | Scopes: 203 | required: 204 | - scopes 205 | properties: 206 | scopes: 207 | type: array 208 | items: 209 | type: string 210 | description: token 类型 211 | Error: 212 | properties: 213 | error_code: 214 | type: integer 215 | format: int32 216 | message: 217 | type: string 218 | text: 219 | type: string 220 | Success: 221 | properties: 222 | ok: 223 | type: boolean 224 | None: 225 | type: object 226 | parameters: 227 | AccessToken: 228 | name: Authorization 229 | in: header 230 | required: true 231 | type: string 232 | Authorization: 233 | description: 格式 (Basic hashkey) 注 hashkey为client_id + client_secret 做BASE64处理 234 | in: header 235 | name: Authorization 236 | required: true 237 | type: string 238 | code: 239 | description: code 240 | in: query 241 | name: code 242 | required: false 243 | type: string 244 | securityDefinitions: 245 | OAuth2: 246 | type: oauth2 247 | flow: password 248 | tokenUrl: https://apis-metis.gusibi.mobi/oauth/token 249 | scopes: 250 | register: 用户注册 251 | open: 开放API 252 | paths: 253 | /oauth/token: 254 | post: 255 | summary: 使用账号换取token 256 | description: | 257 | 账号可以包括微博、微信、Email、手机等多种方式. 258 | 如果使用没有注册过的微博或微信来获取token, 则返回token的 scopes 为 ['register'], 以此表明需要先注册一个用户. 259 | tags: [OAUTH] 260 | operationId: oauth_create_token 261 | parameters: 262 | - $ref: '#/parameters/Authorization' 263 | - $ref: '#/parameters/code' 264 | - name: oauth 265 | in: body 266 | required: true 267 | schema: 268 | $ref: '#/definitions/Authentication' 269 | responses: 270 | 201: 271 | description: access token 272 | schema: 273 | $ref: '#/definitions/TokenDetail' 274 | default: 275 | description: Unexpected error 276 | schema: 277 | $ref: '#/definitions/Error' 278 | security: 279 | - OAuth2: [login, register] 280 | /oauth/token/refresh: 281 | post: 282 | summary: 通过原 token 获取新token 283 | tags: [OAUTH] 284 | parameters: 285 | - $ref: '#/parameters/Authorization' 286 | - name: refresh_token 287 | in: body 288 | required: true 289 | schema: 290 | $ref: '#/definitions/RefreshToken' 291 | responses: 292 | 200: 293 | description: access token 294 | schema: 295 | $ref: '#/definitions/TokenDetail' 296 | default: 297 | description: Unexpected error 298 | schema: 299 | $ref: '#/definitions/Error' 300 | security: 301 | - OAuth2: [login] 302 | /oauth/token/code: 303 | post: 304 | summary: 通过 token_code 获取新token 305 | tags: [OAUTH] 306 | parameters: 307 | - $ref: '#/parameters/Authorization' 308 | - name: refresh_token 309 | in: body 310 | required: true 311 | schema: 312 | $ref: '#/definitions/TokenCode' 313 | responses: 314 | 200: 315 | description: access token 316 | schema: 317 | $ref: '#/definitions/TokenDetail' 318 | default: 319 | description: Unexpected error 320 | schema: 321 | $ref: '#/definitions/Error' 322 | security: 323 | - OAuth2: [login] 324 | /accounts/wxapp: 325 | post: 326 | summary: 微信小程序注册 327 | description: 微信小程序注册 328 | tags: [ACCOUNTS] 329 | operationId: create_accounts_by_wxapp 330 | parameters: 331 | - $ref: '#/parameters/AccessToken' 332 | - name: create_accounts_by_wxapp 333 | in: body 334 | required: ture 335 | schema: 336 | $ref: '#/definitions/CreateWXAPPAccount' 337 | responses: 338 | 201: 339 | description: 帐号信息 340 | schema: 341 | $ref: '#/definitions/Account' 342 | default: 343 | description: Unexpected Error 344 | schema: 345 | $ref: '#/definitions/Error' 346 | security: 347 | - OAuth2: [login] 348 | /accounts/self: 349 | get: 350 | summary: 获取用户详细信息 351 | description: 获取用户详细信息 352 | tags: [ACCOUNTS] 353 | operationId: get_account_detail 354 | parameters: 355 | - $ref: '#/parameters/AccessToken' 356 | responses: 357 | 200: 358 | description: 帐号信息 359 | schema: 360 | $ref: '#/definitions/AccountDetail' 361 | default: 362 | description: Unexpected Error 363 | schema: 364 | $ref: '#/definitions/Error' 365 | security: 366 | - OAuth2: [open] 367 | /self/password: 368 | post: 369 | summary: 设置密码 370 | description: 设置密码 371 | tags: [ACCOUNTS] 372 | operationId: set_password 373 | parameters: 374 | - $ref: '#/parameters/AccessToken' 375 | - name: setting_password 376 | in: body 377 | required: true 378 | schema: 379 | $ref: '#/definitions/Password' 380 | responses: 381 | 201: 382 | schema: 383 | $ref: '#/definitions/Password' 384 | default: 385 | description: Unexpected error 386 | schema: 387 | $ref: '#/definitions/Error' 388 | security: 389 | - OAuth2: [open] 390 | put: 391 | summary: 密码修改 392 | description: 密码修改 393 | tags: [ACCOUNTS] 394 | operationId: update_password 395 | parameters: 396 | - $ref: '#/parameters/AccessToken' 397 | - name: update_password 398 | in: body 399 | required: true 400 | schema: 401 | $ref: '#/definitions/UpdatePassword' 402 | responses: 403 | 200: 404 | schema: 405 | $ref: '#/definitions/Success' 406 | default: 407 | description: Unexpected error 408 | schema: 409 | $ref: '#/definitions/Error' 410 | security: 411 | - OAuth2: [open] 412 | /self/password/reset: 413 | post: 414 | summary: 密码重置 415 | description: 密码重置 416 | tags: [ACCOUNTS] 417 | operationId: reset_password 418 | parameters: 419 | - $ref: '#/parameters/Authorization' 420 | - name: reset_password 421 | in: body 422 | required: true 423 | schema: 424 | $ref: '#/definitions/ResetPassword' 425 | responses: 426 | 200: 427 | schema: 428 | $ref: '#/definitions/Success' 429 | default: 430 | description: Unexpected error 431 | schema: 432 | $ref: '#/definitions/Error' 433 | security: 434 | - OAuth2: [login] 435 | -------------------------------------------------------------------------------- /docs/v1.yml: -------------------------------------------------------------------------------- 1 | swagger: '2.0' 2 | info: 3 | title: Metis v1 API 4 | version: '1.0.0' 5 | schemes: 6 | - http 7 | basePath: /v1 8 | consumes: 9 | - application/json 10 | produces: 11 | - application/json 12 | definitions: 13 | datetime: 14 | type: string 15 | format: datetime 16 | Authentications: 17 | description: 用户详细授权数据 18 | properties: 19 | wxapp: 20 | type: string 21 | mobile: 22 | type: string 23 | Account: 24 | description: account 基本信息 25 | required: 26 | - id 27 | properties: 28 | id: 29 | type: string 30 | nickname: 31 | type: string 32 | avatar: 33 | type: string 34 | AccountDetail: 35 | description: account 信息 36 | required: 37 | - id 38 | properties: 39 | id: 40 | type: string 41 | username: 42 | type: string 43 | nickname: 44 | type: string 45 | avatar: 46 | type: string 47 | authentications: 48 | $ref: '#/definitions/Authentications' 49 | created_time: 50 | $ref: '#/definitions/datetime' 51 | Testing: 52 | required: 53 | - id 54 | - title 55 | - description 56 | properties: 57 | id: 58 | type: string 59 | score: 60 | type: integer 61 | format: int32 62 | image: 63 | type: string 64 | title: 65 | type: string 66 | description: 67 | type: string 68 | question_count: 69 | type: integer 70 | creator: 71 | $ref: '#/definitions/Account' 72 | created_time: 73 | $ref: '#/definitions/datetime' 74 | CreateTest: 75 | required: 76 | - title 77 | - description 78 | properties: 79 | image: 80 | type: string 81 | title: 82 | type: string 83 | minLength: 3 84 | maxLength: 128 85 | description: 86 | type: string 87 | minLength: 6 88 | maxLength: 256 89 | start_time: 90 | $ref: '#/definitions/datetime' 91 | end_time: 92 | $ref: '#/definitions/datetime' 93 | remark: 94 | type: string 95 | UpdateTest: 96 | properties: 97 | image: 98 | type: string 99 | title: 100 | type: string 101 | minLength: 3 102 | maxLength: 128 103 | description: 104 | type: string 105 | minLength: 6 106 | maxLength: 256 107 | remark: 108 | type: string 109 | start_time: 110 | $ref: '#/definitions/datetime' 111 | end_time: 112 | $ref: '#/definitions/datetime' 113 | Test: 114 | required: 115 | - id 116 | - title 117 | - description 118 | properties: 119 | id: 120 | type: string 121 | image: 122 | type: string 123 | title: 124 | type: string 125 | description: 126 | type: string 127 | start_time: 128 | $ref: '#/definitions/datetime' 129 | end_time: 130 | $ref: '#/definitions/datetime' 131 | question_count: 132 | type: integer 133 | creator: 134 | $ref: '#/definitions/Account' 135 | created_time: 136 | $ref: '#/definitions/datetime' 137 | TestDetail: 138 | required: 139 | - id 140 | - title 141 | - description 142 | properties: 143 | id: 144 | type: string 145 | image: 146 | type: string 147 | title: 148 | type: string 149 | description: 150 | type: string 151 | date_start: 152 | type: string 153 | date_end: 154 | type: string 155 | time_start: 156 | type: string 157 | time_end: 158 | type: string 159 | status: 160 | type: string 161 | start_time: 162 | $ref: '#/definitions/datetime' 163 | end_time: 164 | $ref: '#/definitions/datetime' 165 | remark: 166 | type: string 167 | question_count: 168 | type: integer 169 | creator: 170 | $ref: '#/definitions/Account' 171 | created_time: 172 | $ref: '#/definitions/datetime' 173 | Option: 174 | required: 175 | - option 176 | properties: 177 | index: 178 | type: integer 179 | format: int32 180 | option: 181 | type: string 182 | OptionWithAnswer: 183 | required: 184 | - option 185 | - is_checked 186 | properties: 187 | index: 188 | type: integer 189 | format: int32 190 | option: 191 | type: string 192 | is_checked: 193 | type: boolean 194 | Question: 195 | required: 196 | - id 197 | - title 198 | - options 199 | properties: 200 | id: 201 | type: string 202 | title: 203 | type: string 204 | number: 205 | type: integer 206 | format: int32 207 | options: 208 | type: array 209 | items: 210 | $ref: '#/definitions/Option' 211 | QuestionDetail: 212 | required: 213 | - id 214 | - title 215 | - options 216 | properties: 217 | id: 218 | type: string 219 | title: 220 | type: string 221 | type: 222 | type: string 223 | enum: 224 | - single_choice 225 | - multiple_choice 226 | number: 227 | type: integer 228 | format: int32 229 | options: 230 | type: array 231 | items: 232 | $ref: '#/definitions/OptionWithAnswer' 233 | CreateQuestion: 234 | properties: 235 | title: 236 | type: string 237 | type: 238 | type: string 239 | enum: 240 | - single_choice 241 | - multiple_choice 242 | options: 243 | type: array 244 | items: 245 | $ref: '#/definitions/OptionWithAnswer' 246 | UpdateQuestion: 247 | properties: 248 | title: 249 | type: string 250 | type: 251 | type: string 252 | enum: 253 | - single_choice 254 | - multiple_choice 255 | options: 256 | type: array 257 | items: 258 | $ref: '#/definitions/OptionWithAnswer' 259 | AnswerQuestion: 260 | required: 261 | - question_id 262 | - options 263 | properties: 264 | question_id: 265 | type: string 266 | options: 267 | type: array 268 | items: 269 | type: integer 270 | format: int32 271 | AnswerSuccess: 272 | properties: 273 | ok: 274 | type: boolean 275 | last: 276 | type: boolean 277 | Distribution: 278 | properties: 279 | value: 280 | type: string 281 | count: 282 | type: integer 283 | format: int32 284 | TestScore: 285 | properties: 286 | test_id: 287 | type: string 288 | score: 289 | type: integer 290 | rank: 291 | type: integer 292 | TestingStatistics: 293 | properties: 294 | total_count: 295 | type: integer 296 | max_score: 297 | type: integer 298 | min_score: 299 | type: integer 300 | avg_score: 301 | type: integer 302 | distributions: 303 | type: array 304 | items: 305 | $ref: '#/definitions/Distribution' 306 | QCOSConfig: 307 | properties: 308 | sign: 309 | type: string 310 | Error: 311 | properties: 312 | error_code: 313 | type: integer 314 | format: int32 315 | message: 316 | type: string 317 | text: 318 | type: string 319 | Success: 320 | properties: 321 | ok: 322 | type: boolean 323 | None: 324 | type: object 325 | parameters: 326 | AccessToken: 327 | name: Authorization 328 | in: header 329 | required: true 330 | type: string 331 | Authorization: 332 | description: 格式 (Basic hashkey) 注 hashkey为client_id + client_secret 做BASE64处理 333 | in: header 334 | name: Authorization 335 | required: true 336 | type: string 337 | id_in_path: 338 | description: id in path 339 | in: path 340 | name: id 341 | required: true 342 | type: string 343 | test_id_in_path: 344 | description: test id in path 345 | in: path 346 | name: test_id 347 | type: string 348 | required: true 349 | test_status_in_query: 350 | description: test status in query 351 | in: query 352 | name: status 353 | type: string 354 | required: false 355 | enum: 356 | - draft 357 | - published 358 | - withdraw 359 | qcos_path_in_query: 360 | description: cos path 361 | in: query 362 | name: cos_path 363 | type: string 364 | required: false 365 | step_in_query: 366 | description: step in query 367 | in: query 368 | name: step 369 | type: integer 370 | format: int32 371 | default: 0 372 | offset: 373 | description: offset in query 374 | in: query 375 | name: offset 376 | type: integer 377 | format: int32 378 | default: 0 379 | required: false 380 | limit: 381 | description: limit in query 382 | in: query 383 | name: limit 384 | type: integer 385 | format: int32 386 | default: 20 387 | required: false 388 | code: 389 | description: code 390 | in: query 391 | name: code 392 | required: false 393 | type: string 394 | securityDefinitions: 395 | OAuth2: 396 | type: oauth2 397 | flow: password 398 | tokenUrl: https://apis-metis.gusibi.mobi/oauth/token 399 | scopes: 400 | register: 用户注册 401 | open: 开放API 402 | paths: 403 | /self/tests: 404 | get: 405 | summary: 获取测试列表 406 | description: 获取用户自己创建的测试列表 407 | tags: [SELF] 408 | operationId: get_tests 409 | parameters: 410 | - $ref: '#/parameters/AccessToken' 411 | - $ref: '#/parameters/test_status_in_query' 412 | - $ref: '#/parameters/offset' 413 | - $ref: '#/parameters/limit' 414 | responses: 415 | 200: 416 | schema: 417 | type: array 418 | items: 419 | $ref: '#/definitions/TestDetail' 420 | default: 421 | description: Unexpected error 422 | schema: 423 | $ref: '#/definitions/Error' 424 | security: 425 | - OAuth2: [open] 426 | post: 427 | summary: 新建测试 428 | description: 新建测试 429 | tags: [SELF] 430 | operationId: create_test 431 | parameters: 432 | - $ref: '#/parameters/AccessToken' 433 | - name: create_test 434 | in: body 435 | required: true 436 | schema: 437 | $ref: '#/definitions/CreateTest' 438 | responses: 439 | 201: 440 | schema: 441 | $ref: '#/definitions/TestDetail' 442 | default: 443 | description: Unexpected error 444 | schema: 445 | $ref: '#/definitions/Error' 446 | security: 447 | - OAuth2: [open] 448 | /self/tests/{id}: 449 | get: 450 | summary: 创建者获取测试题 451 | description: 创建者获取测试题 452 | tags: [SELF] 453 | operationId: get_test_questions 454 | parameters: 455 | - $ref: '#/parameters/AccessToken' 456 | - $ref: '#/parameters/id_in_path' 457 | responses: 458 | 200: 459 | schema: 460 | $ref: '#/definitions/TestDetail' 461 | default: 462 | description: Unexpected error 463 | schema: 464 | $ref: '#/definitions/Error' 465 | security: 466 | - OAuth2: [open] 467 | put: 468 | summary: 修改测试 469 | description: 修改测试(不可修改测试状态) 470 | tags: [SELF] 471 | operationId: update_test 472 | parameters: 473 | - $ref: '#/parameters/AccessToken' 474 | - $ref: '#/parameters/id_in_path' 475 | - name: update_test 476 | in: body 477 | required: true 478 | schema: 479 | $ref: '#/definitions/UpdateTest' 480 | responses: 481 | 200: 482 | schema: 483 | $ref: '#/definitions/TestDetail' 484 | default: 485 | description: Unexpected error 486 | schema: 487 | $ref: '#/definitions/Error' 488 | security: 489 | - OAuth2: [open] 490 | delete: 491 | summary: 删除测试 492 | description: 删除测试,只能删除未发布的测试 493 | tags: [SELF] 494 | operationId: delete_test 495 | parameters: 496 | - $ref: '#/parameters/AccessToken' 497 | - $ref: '#/parameters/id_in_path' 498 | responses: 499 | 204: 500 | schema: 501 | $ref: '#/definitions/Success' 502 | default: 503 | description: Unexpected error 504 | schema: 505 | $ref: '#/definitions/Error' 506 | security: 507 | - OAuth2: [open] 508 | /self/tests/{id}/publish: 509 | put: 510 | summary: 发布测试 511 | description: 发布测试,必须有问题才可以发布 512 | tags: [SELF] 513 | operationId: publish_test 514 | parameters: 515 | - $ref: '#/parameters/AccessToken' 516 | - $ref: '#/parameters/id_in_path' 517 | responses: 518 | 200: 519 | schema: 520 | $ref: '#/definitions/TestDetail' 521 | default: 522 | description: Unexpected error 523 | schema: 524 | $ref: '#/definitions/Error' 525 | security: 526 | - OAuth2: [open] 527 | delete: 528 | summary: 取消测试发布 529 | description: 取消测试发布 530 | tags: [SELF] 531 | operationId: delete_test 532 | parameters: 533 | - $ref: '#/parameters/AccessToken' 534 | - $ref: '#/parameters/id_in_path' 535 | responses: 536 | 204: 537 | schema: 538 | $ref: '#/definitions/Success' 539 | default: 540 | description: Unexpected error 541 | schema: 542 | $ref: '#/definitions/Error' 543 | security: 544 | - OAuth2: [open] 545 | /self/tests/{id}/questions: 546 | get: 547 | summary: 获取测试问题列表 548 | description: 获取用户自己的测试问题列表, 包含答案 549 | tags: [SELF] 550 | operationId: get_test_questions 551 | parameters: 552 | - $ref: '#/parameters/AccessToken' 553 | - $ref: '#/parameters/id_in_path' 554 | - $ref: '#/parameters/step_in_query' 555 | responses: 556 | 200: 557 | schema: 558 | type: array 559 | items: 560 | $ref: '#/definitions/QuestionDetail' 561 | default: 562 | description: Unexpected error 563 | schema: 564 | $ref: '#/definitions/Error' 565 | security: 566 | - OAuth2: [open] 567 | post: 568 | summary: 给测试添加问题 569 | description: 给测试添加问题,发布后的测试不能修改 570 | tags: [SELF] 571 | operationId: create_question 572 | parameters: 573 | - $ref: '#/parameters/AccessToken' 574 | - $ref: '#/parameters/id_in_path' 575 | - name: create_question 576 | in: body 577 | required: true 578 | schema: 579 | $ref: '#/definitions/CreateQuestion' 580 | responses: 581 | 201: 582 | schema: 583 | $ref: '#/definitions/QuestionDetail' 584 | default: 585 | description: Unexpected error 586 | schema: 587 | $ref: '#/definitions/Error' 588 | security: 589 | - OAuth2: [open] 590 | /self/tests/{test_id}/questions/{id}: 591 | put: 592 | summary: 修改测试问题 593 | description: 修改问题,发布后的测试不能修改 594 | tags: [SELF] 595 | operationId: update_test_question 596 | parameters: 597 | - $ref: '#/parameters/AccessToken' 598 | - $ref: '#/parameters/test_id_in_path' 599 | - $ref: '#/parameters/id_in_path' 600 | - name: update_question 601 | in: body 602 | required: true 603 | schema: 604 | $ref: '#/definitions/UpdateQuestion' 605 | responses: 606 | 200: 607 | schema: 608 | $ref: '#/definitions/Question' 609 | default: 610 | description: Unexpected error 611 | schema: 612 | $ref: '#/definitions/Error' 613 | security: 614 | - OAuth2: [open] 615 | /self/testings: 616 | get: 617 | summary: 获取自己做过的测试列表 618 | description: 获取用户自己做过的测试列表 619 | tags: [SELF] 620 | operationId: get_tests 621 | parameters: 622 | - $ref: '#/parameters/AccessToken' 623 | - $ref: '#/parameters/offset' 624 | - $ref: '#/parameters/limit' 625 | responses: 626 | 200: 627 | schema: 628 | type: array 629 | items: 630 | $ref: '#/definitions/Testing' 631 | default: 632 | description: Unexpected error 633 | schema: 634 | $ref: '#/definitions/Error' 635 | security: 636 | - OAuth2: [open] 637 | /tests/banner: 638 | get: 639 | summary: 首页banner 640 | description: 首页banner 使用 banner 字段筛选 641 | tags: [TESTS] 642 | operationId: get_test_banner 643 | parameters: 644 | - $ref: '#/parameters/AccessToken' 645 | responses: 646 | 200: 647 | schema: 648 | type: array 649 | items: 650 | $ref: '#/definitions/Test' 651 | default: 652 | description: Unexpected error 653 | schema: 654 | $ref: '#/definitions/Error' 655 | security: 656 | - OAuth2: [open] 657 | /tests/handpick: 658 | get: 659 | summary: 首页测试列表 660 | description: 首页测试列表 加 handpick 字段筛选 661 | tags: [TESTS] 662 | operationId: get_test_handpick 663 | parameters: 664 | - $ref: '#/parameters/AccessToken' 665 | responses: 666 | 200: 667 | schema: 668 | type: array 669 | items: 670 | $ref: '#/definitions/Test' 671 | default: 672 | description: Unexpected error 673 | schema: 674 | $ref: '#/definitions/Error' 675 | security: 676 | - OAuth2: [open] 677 | /tests/{id}: 678 | get: 679 | summary: 用户获取测试题 680 | description: 用户获取测试题 681 | tags: [TESTING] 682 | operationId: get_test_questions 683 | parameters: 684 | - $ref: '#/parameters/AccessToken' 685 | - $ref: '#/parameters/id_in_path' 686 | responses: 687 | 200: 688 | schema: 689 | $ref: '#/definitions/Test' 690 | default: 691 | description: Unexpected error 692 | schema: 693 | $ref: '#/definitions/Error' 694 | security: 695 | - OAuth2: [open] 696 | /tests/{id}/questions: 697 | get: 698 | summary: 用户获取测试问题 699 | description: 用户获取测试问题 700 | tags: [TESTING] 701 | operationId: get_test_questions 702 | parameters: 703 | - $ref: '#/parameters/AccessToken' 704 | - $ref: '#/parameters/id_in_path' 705 | responses: 706 | 200: 707 | schema: 708 | type: array 709 | items: 710 | $ref: '#/definitions/Question' 711 | default: 712 | description: Unexpected error 713 | schema: 714 | $ref: '#/definitions/Error' 715 | security: 716 | - OAuth2: [open] 717 | /tests/{id}/score: 718 | get: 719 | summary: 用户测试分数 720 | description: 用户测试分数 721 | tags: [TESTING] 722 | operationId: get_test_score 723 | parameters: 724 | - $ref: '#/parameters/AccessToken' 725 | - $ref: '#/parameters/id_in_path' 726 | responses: 727 | 200: 728 | schema: 729 | $ref: '#/definitions/TestScore' 730 | default: 731 | description: Unexpected error 732 | schema: 733 | $ref: '#/definitions/Error' 734 | security: 735 | - OAuth2: [open] 736 | /tests/{id}/answers: 737 | post: 738 | summary: 答题 739 | description: 回答测试问题 740 | tags: [TESTING] 741 | operationId: answer_test 742 | parameters: 743 | - $ref: '#/parameters/AccessToken' 744 | - $ref: '#/parameters/id_in_path' 745 | - name: answer_test 746 | in: body 747 | required: true 748 | schema: 749 | $ref: '#/definitions/AnswerQuestion' 750 | responses: 751 | 201: 752 | schema: 753 | $ref: '#/definitions/AnswerSuccess' 754 | default: 755 | description: Unexpected error 756 | schema: 757 | $ref: '#/definitions/Error' 758 | security: 759 | - OAuth2: [open] 760 | /tests/{id}/statistics: 761 | get: 762 | summary: 测试统计结果 763 | description: 测试统计结果 分数段占比 764 | tags: [TESTING] 765 | operationId: get_test_questions 766 | parameters: 767 | - $ref: '#/parameters/AccessToken' 768 | - $ref: '#/parameters/id_in_path' 769 | responses: 770 | 200: 771 | schema: 772 | $ref: '#/definitions/TestingStatistics' 773 | default: 774 | description: Unexpected error 775 | schema: 776 | $ref: '#/definitions/Error' 777 | security: 778 | - OAuth2: [open] 779 | /qc_cos/config: 780 | get: 781 | summary: 腾讯云配置 782 | description: 腾讯云配置 783 | tags: [Config] 784 | operationId: get_qc_cos_config 785 | parameters: 786 | - $ref: '#/parameters/AccessToken' 787 | - $ref: '#/parameters/qcos_path_in_query' 788 | responses: 789 | 200: 790 | schema: 791 | $ref: '#/definitions/QCOSConfig' 792 | default: 793 | description: Unexpected error 794 | schema: 795 | $ref: '#/definitions/Error' 796 | security: 797 | - OAuth2: [open] 798 | -------------------------------------------------------------------------------- /manager.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import asyncio 4 | from signal import signal, SIGINT 5 | 6 | import uvloop 7 | 8 | from apis.app import app 9 | 10 | 11 | def run(): 12 | asyncio.set_event_loop(uvloop.new_event_loop()) 13 | server = app.create_server(host="0.0.0.0", port=7777) 14 | loop = asyncio.get_event_loop() 15 | asyncio.ensure_future(server) 16 | signal(SIGINT, lambda s, f: loop.stop()) 17 | try: 18 | loop.run_forever() 19 | except: 20 | loop.stop() 21 | 22 | 23 | if __name__ == '__main__': 24 | run() 25 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | six 2 | jsonschema 3 | git+https://github.com/gusibi/cos-python-sdk-v4.git@master 4 | python-weixin 5 | sanic 6 | xmltodict 7 | pyyaml 8 | pycrypto 9 | swagger-py-codegen 10 | PyJWT 11 | mongoengine 12 | py-bcrypt 13 | arrow 14 | werkzeug 15 | --------------------------------------------------------------------------------