├── .gitignore
├── LICENSE.txt
├── README.md
├── __init__.py
├── __manifest__.py
├── controllers
├── __init__.py
├── controllers.py
├── mail.py
├── oauth_login_ext.py
└── oauth_signin_3rd.py
├── data
└── oauth_provider.xml
├── models
├── __init__.py
├── res_partner.py
├── res_users.py
├── wo_config.py
└── wo_confirm_wizard.py
├── rpc
└── __init__.py
├── security
└── ir.model.access.csv
└── views
├── res_users_views.xml
├── wo_config_views.xml
├── wo_confirm_views.xml
└── wx_login.xml
/.gitignore:
--------------------------------------------------------------------------------
1 | *.py[co]
2 |
3 | # C extensions
4 | *.so
5 |
6 |
7 | # Mr Developer
8 | .mr.developer.cfg
9 | .project
10 | .pydevproject
11 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 The Data Incubator
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## WeOdoo
2 | Odoo 快速接入企业微信,快捷使用,基于Oauth2.0安全认证协议,免对接开发配置,支持局域网等内网环境的 Odoo 服务
3 |
4 |
5 | ## 特性
6 | * 账号授权绑定
7 | * PC端扫码登录、企业微信端授权登录,首次自助登录绑定用户
8 | * 可绑定自有的甚至本地的Odoo服务地址,企业微信端自动授权登录绑定的Odoo用户
9 | * 可自由发送通知消息到企业微信
10 | * Odoo单据mail消息自动发送企业微信通知,点开通知直接进入Odoo单据页面
11 | * 实现了Odoo融合到企业微信的移动化办公
12 | * 支持Odoo10、11、12
13 |
14 |
15 | ## 使用
16 |
17 | - 下载Odoo模块wedooo并安装
18 | - 在登录页面点“企业微信登录”按提示安装好企业微信手机端应用
19 | - 将得到的授权应用Key和授权应用Secret填入Odoo的【设置】-【WeOdoo设置】页即可
20 |
21 | 
22 |
23 | 
24 |
25 | 
26 |
27 |
28 |
--------------------------------------------------------------------------------
/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | from . import controllers
4 | from . import models
--------------------------------------------------------------------------------
/__manifest__.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | {
3 | 'name': "WeOdoo",
4 | 'summary': """企业微信快捷使用,免对接""",
5 | 'description': """""",
6 | 'author': 'Oejia',
7 | 'website': 'http://www.oejia.net/',
8 | 'category': '',
9 | 'version': '0.1',
10 | 'depends': ['auth_oauth'],
11 | 'application': True,
12 | 'data': [
13 | 'security/ir.model.access.csv',
14 |
15 | 'views/wx_login.xml',
16 | 'data/oauth_provider.xml',
17 | 'views/wo_config_views.xml',
18 | 'views/wo_confirm_views.xml',
19 | 'views/res_users_views.xml',
20 | ],
21 | 'qweb': [],
22 | 'demo': [],
23 | }
24 |
--------------------------------------------------------------------------------
/controllers/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | from . import controllers
4 | from . import oauth_signin_3rd
5 | from . import oauth_login_ext
6 | from . import mail
7 |
--------------------------------------------------------------------------------
/controllers/controllers.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import logging
3 | import time
4 | import random
5 | import json
6 | import base64
7 |
8 | from odoo import http
9 | from odoo.addons.web.controllers.main import Home, ensure_db
10 | from odoo.addons.web.controllers.main import db_monodb, ensure_db, set_cookie_and_redirect, login_and_redirect
11 | import requests
12 | from odoo.http import request
13 |
14 |
15 |
16 | _logger = logging.getLogger(__name__)
17 |
18 | QR_DICT = {}
19 |
20 |
21 | def gen_id(data):
22 | _now = time.time()
23 | # 回收过期的ID
24 | for k,v in list(QR_DICT.items()):
25 | if _now - v['ts'] > 600:
26 | del QR_DICT[k]
27 | # 生成ID
28 | tm = str(int(_now*100))[-7:]
29 | _id = str(random.randint(1,9)) + tm
30 | QR_DICT[_id] = {'ts':_now, 'state': 'gen', 'data': data}
31 | return _id
32 |
33 |
34 | class SocialLogin(http.Controller):
35 |
36 |
37 | @http.route('/corp/bind', type='http', auth="public", website=True)
38 | def wx_bind(self, **kw):
39 | qr_id = kw.get('qr_id')
40 | redirect = kw.get('redirect', '')
41 | redirect = base64.urlsafe_b64decode(redirect.encode('utf-8')).decode('utf-8')
42 | _info = QR_DICT[qr_id]['data']
43 |
44 | values = request.params.copy()
45 | if redirect:
46 | values['login_url'] = '/web/login?qr_id=%s&redirect=%s'%(qr_id, redirect)
47 | else:
48 | values['login_url'] = '/web/login?qr_id=%s'%qr_id
49 | values['avatar'] = _info['avatar']
50 | values['name'] = _info['name']
51 | request.session['qr_id'] = qr_id
52 | return request.render('weodoo.wx_bind', values)
53 |
54 |
--------------------------------------------------------------------------------
/controllers/mail.py:
--------------------------------------------------------------------------------
1 | # coding=utf-8
2 |
3 | import logging
4 | import werkzeug
5 | import werkzeug.utils
6 |
7 |
8 | from odoo import http
9 | from odoo.http import request
10 | from odoo.addons.mail.controllers.main import MailController
11 |
12 | _logger = logging.getLogger(__name__)
13 |
14 |
15 | class MailControllerExt(MailController):
16 |
17 | @http.route()
18 | def mail_action_view(self, **kwargs):
19 | _logger.info('>>> %s'%request.httprequest.url)
20 | _logger.info('>>>mail_action_view %s'%kwargs)
21 | if not request.session.uid:
22 | # X2Z0eXBlPXdv
23 | return werkzeug.utils.redirect('/web/login?_fm=X2Z0eXBlPXdv&redirect=%s'%werkzeug.url_quote_plus(request.httprequest.url), 303)
24 | res = super(MailControllerExt, self).mail_action_view(**kwargs)
25 | return res
26 |
--------------------------------------------------------------------------------
/controllers/oauth_login_ext.py:
--------------------------------------------------------------------------------
1 | # coding=utf-8
2 |
3 | import logging
4 | import werkzeug.utils
5 | import werkzeug
6 | import base64
7 | import json
8 |
9 | import odoo
10 | from odoo import http
11 | from odoo.http import request
12 | from odoo.addons import auth_signup
13 | from odoo.addons.auth_oauth.controllers.main import OAuthLogin
14 |
15 |
16 | _logger = logging.getLogger(__name__)
17 |
18 |
19 | class AuthSignupHome(OAuthLogin):
20 |
21 | def _deal_state_r(self, state):
22 | _logger.info('>>> get_state %s'%request.httprequest.url)
23 | _fm = request.params.get('_fm', None)
24 | if _fm:
25 | fragment = base64.urlsafe_b64decode(_fm.encode('utf-8')).decode('utf-8')
26 | fragment = fragment.replace('_ftype=wo', '')
27 | r = werkzeug.url_unquote_plus(state.get('r', ''))
28 | state['r'] = werkzeug.url_quote_plus('%s#%s'%(r, fragment))
29 | return state
30 |
31 | def _get_auth_link_wo(self, provider=None):
32 | if not provider:
33 | provider = request.env(user=1).ref('weodoo.provider_third')
34 |
35 | return_url = request.httprequest.url_root + 'auth_oauth/signin3rd'
36 | state = self.get_state(provider)
37 | self._deal_state_r(state)
38 | params = dict(
39 | response_type='token',
40 | client_id=provider['client_id'],
41 | redirect_uri=return_url,
42 | scope=provider['scope'],
43 | state=json.dumps(state),
44 | )
45 | return "%s?%s" % (provider['auth_endpoint'], werkzeug.url_encode(params))
46 |
47 | def list_providers(self):
48 | providers = super(AuthSignupHome, self).list_providers()
49 | weodoo_provider = request.env(user=1).ref('weodoo.provider_third')
50 | for provider in providers:
51 | if provider['id']==weodoo_provider.id:
52 | provider['auth_link'] = self._get_auth_link_wo(provider)
53 | break
54 | return providers
55 |
56 |
57 | @http.route()
58 | def web_login(self, *args, **kw):
59 | if request.httprequest.method == 'GET':
60 | if request.session.uid and request.params.get('redirect'):
61 | return http.redirect_with_hash(request.params.get('redirect'))
62 | fm = request.params.get('_fm', None)
63 | if not request.session.uid and fm!=None:
64 | fragment = base64.urlsafe_b64decode(fm.encode('utf-8')).decode('utf-8')
65 | if '_ftype=wo' in fragment:
66 | auth_link = self._get_auth_link_wo()
67 | return werkzeug.utils.redirect(auth_link, 303)
68 |
69 | response = super(AuthSignupHome, self).web_login(*args, **kw)
70 |
71 | from .controllers import QR_DICT
72 | qr_id = str(request.session.get('qr_id', ''))#kw.get('qr_id', False)
73 | if qr_id and (request.params['login_success'] or request.session.uid):
74 | from .controllers import QR_DICT
75 | if qr_id in QR_DICT:
76 | qr = QR_DICT[qr_id]
77 | if 1:#qr['state']=='fail' and qr['openid']:
78 | # 绑定当前登录的用户
79 | if request.session.uid:
80 | user = request.env["res.users"].sudo().search(([('id','=',request.session.uid)]))
81 | else:
82 | user = request.env.user
83 | user.write({
84 | 'oauth_provider_id': qr['data']['oauth_provider_id'],
85 | 'oauth_uid': qr['data']['user_id'],
86 | })
87 | request.env.cr.commit()
88 |
89 | return response
90 |
91 | @http.route()
92 | def web_client(self, s_action=None, **kw):
93 | res = super(AuthSignupHome, self).web_client(s_action, **kw)
94 | if not request.session.uid:
95 | fm = request.params.get('_fm', None)
96 | if fm!=None:
97 | res = werkzeug.utils.redirect('/web/login?_fm=%s'%fm, 303)
98 | return res
99 |
--------------------------------------------------------------------------------
/controllers/oauth_signin_3rd.py:
--------------------------------------------------------------------------------
1 | # coding=utf-8
2 | import werkzeug
3 | import json
4 | import logging
5 | import base64
6 |
7 | from odoo import http
8 | from odoo.http import request
9 | from odoo.addons.auth_oauth.controllers.main import OAuthController, fragment_to_query_string
10 | from odoo.addons.auth_oauth.controllers.main import OAuthLogin
11 |
12 | from odoo.addons.web.controllers.main import db_monodb, ensure_db, set_cookie_and_redirect, login_and_redirect
13 | from odoo import registry as registry_get
14 | from odoo import api, http, SUPERUSER_ID, _
15 |
16 | from odoo.exceptions import AccessDenied
17 |
18 | _logger = logging.getLogger(__name__)
19 |
20 |
21 | class OAuthControllerExt(OAuthController):
22 |
23 | #@http.route()
24 | @http.route('/auth_oauth/signin3rd', type='http', auth='none')
25 | @fragment_to_query_string
26 | def signin_3rd(self, **kw):
27 | state = json.loads(kw['state'])
28 | dbname = state['d']
29 | provider = state['p']
30 | context = state.get('c', {})
31 | registry = registry_get(dbname)
32 | with registry.cursor() as cr:
33 | try:
34 | env = api.Environment(cr, SUPERUSER_ID, context)
35 | credentials = env['res.users'].sudo().auth_oauth_third(provider, kw)
36 | cr.commit()
37 | action = state.get('a')
38 | menu = state.get('m')
39 | redirect = werkzeug.url_unquote_plus(state['r']) if state.get('r') else False
40 | url = '/web'
41 | if redirect:
42 | url = redirect
43 | elif action:
44 | url = '/web#action=%s' % action
45 | elif menu:
46 | url = '/web#menu_id=%s' % menu
47 | if credentials[0]==-1:
48 | from .controllers import gen_id
49 | credentials[1]['oauth_provider_id'] = provider
50 | qr_id = gen_id(credentials[1])
51 | redirect = base64.urlsafe_b64encode(redirect.encode('utf-8')).decode('utf-8')
52 | url = '/corp/bind?qr_id=%s&redirect=%s'%(qr_id, redirect)
53 | else:
54 | return login_and_redirect(*credentials, redirect_url=url)
55 | except AttributeError:
56 | import traceback;traceback.print_exc()
57 | # auth_signup is not installed
58 | _logger.error("auth_signup not installed on database %s: oauth sign up cancelled." % (dbname,))
59 | url = "/web/login?oauth_error=1"
60 | except AccessDenied:
61 | import traceback;traceback.print_exc()
62 | # oauth credentials not valid, user could be on a temporary session
63 | _logger.info('OAuth2: access denied, redirect to main page in case a valid session exists, without setting cookies')
64 | url = "/web/login?oauth_error=3"
65 | redirect = werkzeug.utils.redirect(url, 303)
66 | redirect.autocorrect_location_header = False
67 | return redirect
68 | except Exception as e:
69 | # signup error
70 | _logger.exception("OAuth2: %s" % str(e))
71 | url = "/web/login?oauth_error=2"
72 |
73 | return set_cookie_and_redirect(url)
74 |
75 |
--------------------------------------------------------------------------------
/data/oauth_provider.xml:
--------------------------------------------------------------------------------
1 |
2 |
','').replace('
','') 30 | _content = u'%s\n%s'%(message.subject, _body) if message.subject else _body 31 | _head = u'%s 发送到 %s'%(message.author_id.name, message.record_name) 32 | try: 33 | message_content = u'%s:%s'%(_head,_content) 34 | partner.send_corp_msg({"mtype": "card", "title": _head, 'description': _content, 'url': '%s/mail/view?message_id=%s'%(base_url, message.id)}) 35 | except: 36 | import traceback;traceback.print_exc() 37 | 38 | def send_corp_msg(self, msg): 39 | from ..rpc import send_msg 40 | send_msg(self.env, [self.user_ids[0].oauth_uid], msg) 41 | 42 | def get_corp_key(self): 43 | if self.user_ids: 44 | return self.user_ids[0].oauth_uid 45 | -------------------------------------------------------------------------------- /models/res_users.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import werkzeug 3 | import json 4 | try: 5 | import urlparse 6 | except: 7 | from urllib.parse import urlparse 8 | try: 9 | import urllib2 10 | except: 11 | from urllib import request as urllib2 12 | import logging 13 | 14 | from odoo import models, fields, api 15 | from odoo.exceptions import AccessDenied 16 | 17 | 18 | _logger = logging.getLogger(__name__) 19 | 20 | class ResUsers(models.Model): 21 | 22 | _inherit = 'res.users' 23 | 24 | 25 | @api.model 26 | def _auth_oauth_signin_third(self, provider, validation, params): 27 | oauth_user = self.search([("oauth_uid", "=", validation['user_id']), ('oauth_provider_id', '=', provider)]) 28 | if not oauth_user: 29 | return -1 30 | else: 31 | return self._auth_oauth_signin(provider, validation, params) 32 | 33 | @api.model 34 | def auth_oauth_third(self, provider, params): 35 | # Advice by Google (to avoid Confused Deputy Problem) 36 | # if validation.audience != OUR_CLIENT_ID: 37 | # abort() 38 | # else: 39 | # continue with the process 40 | access_token = params.get('access_token') 41 | validation = self._auth_oauth_validate(provider, access_token) 42 | # required check 43 | if not validation.get('user_id'): 44 | # Workaround: facebook does not send 'user_id' in Open Graph Api 45 | if validation.get('id'): 46 | validation['user_id'] = validation['id'] 47 | else: 48 | raise AccessDenied() 49 | 50 | # retrieve and sign in user 51 | login = self._auth_oauth_signin_third(provider, validation, params) 52 | if login==-1: 53 | return login, validation 54 | if not login: 55 | raise AccessDenied() 56 | # return user credentials 57 | return (self.env.cr.dbname, login, access_token) 58 | 59 | 60 | def is_available(self): 61 | return self.oauth_uid or super(ResUsers, self).is_available() 62 | 63 | def send_corp_text(self, text): 64 | msg = { 65 | "mtype": "text", 66 | "content": text, 67 | } 68 | self.partner_id.send_corp_msg(msg) 69 | 70 | @api.multi 71 | def send_corp_text_confirm(self): 72 | self.ensure_one() 73 | 74 | new_context = dict(self._context) or {} 75 | new_context['default_model'] = 'res.users' 76 | new_context['default_method'] = 'send_corp_text' 77 | new_context['record_ids'] = self.id 78 | return { 79 | 'name': u'发送企业微信消息', 80 | 'type': 'ir.actions.act_window', 81 | 'res_model': 'wo.confirm', 82 | 'res_id': None, 83 | 'view_mode': 'form', 84 | 'view_type': 'form', 85 | 'context': new_context, 86 | 'view_id': self.env.ref('weodoo.wo_confirm_view_form_send').id, 87 | 'target': 'new' 88 | } 89 | -------------------------------------------------------------------------------- /models/wo_config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from odoo import models, fields, api 4 | 5 | 6 | class WeOdooConfig(models.Model): 7 | 8 | _name = 'wo.config' 9 | _description = u'WeOdoo设置' 10 | 11 | oauth_client_key = fields.Char('授权应用Key') 12 | oauth_client_secret = fields.Char('授权应用Secret') 13 | enable_wx_notify = fields.Boolean('启用企业微信通知', default=True) 14 | 15 | @api.multi 16 | def name_get(self): 17 | return [(obj.id, "WeOdoo 设置") for obj in self] 18 | 19 | 20 | @api.multi 21 | def write(self, vals): 22 | if "oauth_client_key" in vals: 23 | vals["oauth_client_key"] = vals["oauth_client_key"].lstrip().rstrip() 24 | result = super(WeOdooConfig, self).write(vals) 25 | third_provider = self.env.ref('weodoo.provider_third') 26 | if "oauth_client_key" in vals: 27 | third_provider.write({"client_id": vals["oauth_client_key"]}) 28 | return result 29 | -------------------------------------------------------------------------------- /models/wo_confirm_wizard.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | from odoo import models, fields, api 4 | 5 | 6 | class WxConfirm(models.TransientModel): 7 | 8 | _name = 'wo.confirm' 9 | _description = u'确认' 10 | 11 | info = fields.Text("信息") 12 | model = fields.Char('模型') 13 | method = fields.Char('方法') 14 | 15 | api.multi 16 | def execute(self): 17 | self.ensure_one() 18 | active_ids = self._context.get('record_ids') 19 | rs = self.env[self.model].browse(active_ids) 20 | ret = getattr(rs, self.method)() 21 | return ret 22 | 23 | api.multi 24 | def execute_with_info(self): 25 | self.ensure_one() 26 | active_ids = self._context.get('record_ids') 27 | rs = self.env[self.model].browse(active_ids) 28 | ret = getattr(rs, self.method)(self.info) 29 | return ret 30 | -------------------------------------------------------------------------------- /rpc/__init__.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | import logging 3 | import requests 4 | 5 | _logger = logging.getLogger(__name__) 6 | 7 | 8 | oauth_client_cache = {} 9 | 10 | def send_msg(env, slug_list, msg): 11 | if env.cr.dbname not in oauth_client_cache: 12 | obj = env.ref('weodoo.weodoo_config_default') 13 | oauth_client_cache[env.cr.dbname] = { 14 | "id": obj.id, 15 | "oauth_key": obj.oauth_client_key, 16 | "oauth_secret": obj.oauth_client_secret, 17 | } 18 | oauth_client = oauth_client_cache[env.cr.dbname] 19 | 20 | mtype = msg["mtype"] 21 | if mtype=="text": 22 | data = { 23 | "oauth_key": oauth_client["oauth_key"], 24 | "oauth_secret": oauth_client["oauth_secret"], 25 | "slug": ','.join(slug_list), 26 | "content": msg["content"], 27 | } 28 | url = "https://i.calluu.cn/auth3rd/send_text" 29 | ret = requests.post(url, data) 30 | _logger.info(ret) 31 | elif mtype=='card': 32 | data = { 33 | "oauth_key": oauth_client["oauth_key"], 34 | "oauth_secret": oauth_client["oauth_secret"], 35 | "slug": ','.join(slug_list), 36 | "title": msg["title"], 37 | "description": msg["description"], 38 | "url": msg["url"], 39 | "btntxt": msg.get("btntxt", "详情"), 40 | } 41 | url = "https://i.calluu.cn/auth3rd/send_card" 42 | ret = requests.post(url, data) 43 | _logger.info(ret) 44 | elif mtype=='image': 45 | pass 46 | elif mtype=='voice': 47 | pass 48 | -------------------------------------------------------------------------------- /security/ir.model.access.csv: -------------------------------------------------------------------------------- 1 | id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink 2 | 3 | access_wo_config_group_system,wo_config.group_system,model_wo_config,base.group_system,1,1,0,0 -------------------------------------------------------------------------------- /views/res_users_views.xml: -------------------------------------------------------------------------------- 1 | 2 |