企业微信 通步
This commit is contained in:
@@ -6,25 +6,15 @@
|
||||
<field name="name">采购办公用品</field>
|
||||
<field name="approval_type">purchase_work</field>
|
||||
</record>
|
||||
<!-- 天河投资办公室 - 党总支发文 -->
|
||||
<record id="approval_category_data_tianhe_general_party_branch_post" model="approval.category">
|
||||
<field name="name">党总支发文</field>
|
||||
<field name="approval_type">general_party_branch_post</field>
|
||||
</record>
|
||||
<!-- 天河投资办公室 - 建设工程业务呈批表 -->
|
||||
<record id="approval_category_data_tianhe_construction_business_approval" model="approval.category">
|
||||
<field name="name">建设工程业务呈批表</field>
|
||||
<field name="approval_type">construction_business</field>
|
||||
</record>
|
||||
<!-- 天河投资办公室 - 董事会议题申报表 -->
|
||||
<record id="approval_category_data_tianhe_dsh_issues_declarations" model="approval.category">
|
||||
<field name="name">董事会议题申报表</field>
|
||||
<field name="approval_type">tianhe_dsh_issues_declarations</field>
|
||||
</record>
|
||||
<!-- 天河投资办公室 - 工资呈批表 -->
|
||||
<record id="approval_category_data_tianhe_salary_approval" model="approval.category">
|
||||
<field name="name">工资呈批表</field>
|
||||
<field name="approval_type">tianhe_dsh_issues_declarations</field>
|
||||
<field name="approval_type">salary_approval</field>
|
||||
</record>
|
||||
<!-- 天河投资办公室 - 经营班子申报表 -->
|
||||
<record id="approval_category_data_tianhe_management_declare_approval" model="approval.category">
|
||||
@@ -32,11 +22,6 @@
|
||||
<field name="approval_type">management_declare</field>
|
||||
<field name="sequence">1</field>
|
||||
</record>
|
||||
<!-- 天河投资办公室 - 公文发文(子企业) -->
|
||||
<record id="approval_category_data_tianhe_official_issues_for_son" model="approval.category">
|
||||
<field name="name">公文发文(子企业)</field>
|
||||
<field name="approval_type">official_issues_for_son</field>
|
||||
</record>
|
||||
<!-- 天河投资办公室 - 公文发文 -->
|
||||
<record id="approval_category_data_tianhe_official_issues" model="approval.category">
|
||||
<field name="name">公文发文</field>
|
||||
@@ -67,11 +52,6 @@
|
||||
<field name="name">诉讼事项录入表</field>
|
||||
<field name="approval_type">contentious_matter</field>
|
||||
</record>
|
||||
<!-- 天河投资办公室 - 投融资发文 -->
|
||||
<record id="approval_category_data_tianhe_financing_post_approval" model="approval.category">
|
||||
<field name="name">投融资发文</field>
|
||||
<field name="approval_type">financing_post</field>
|
||||
</record>
|
||||
<!-- 天河投资办公室 - 业务呈批表 -->
|
||||
<record id="approval_category_data_tianhe_business_approval" model="approval.category">
|
||||
<field name="name">业务呈批表</field>
|
||||
@@ -95,27 +75,27 @@
|
||||
<!-- 公司通用表单- 补卡 -->
|
||||
<record id="approval_category_data_generic_forms_replacement_card" model="approval.category">
|
||||
<field name="name">补卡</field>
|
||||
<field name="approval_type">generic_forms_replacement_card</field>
|
||||
<field name="approval_type">card_approval</field>
|
||||
</record>
|
||||
<!-- 公司通用表单- 加班 -->
|
||||
<record id="approval_category_data_generic_forms_overtime" model="approval.category">
|
||||
<field name="name">加班</field>
|
||||
<field name="approval_type">generic_forms_overtime</field>
|
||||
<field name="approval_type">overtime_approval</field>
|
||||
</record>
|
||||
<!-- 公司通用表单- 外出 -->
|
||||
<record id="approval_category_data_generic_forms_outing" model="approval.category">
|
||||
<field name="name">外出</field>
|
||||
<field name="approval_type">generic_forms_outing</field>
|
||||
<field name="approval_type">go_approval</field>
|
||||
</record>
|
||||
<!-- 公司通用表单- 销假 -->
|
||||
<record id="approval_category_data_generic_forms_remove_holiday" model="approval.category">
|
||||
<field name="name">销假</field>
|
||||
<field name="approval_type">generic_forms_remove_holiday</field>
|
||||
<field name="approval_type">remove_holiday_approval</field>
|
||||
</record>
|
||||
<!-- 公司通用表单 - 通用离职表 -->
|
||||
<record id="approval_category_data_tianhe_dimission_approval" model="approval.category">
|
||||
<field name="name">离职表</field>
|
||||
<field name="approval_type">dimission</field>
|
||||
<field name="approval_type">exit_form</field>
|
||||
</record>
|
||||
<!-- 公司通用表单 - 试用期转正式员工 -->
|
||||
<record id="approval_category_data_tianhe_tryout_official_approval" model="approval.category">
|
||||
@@ -132,12 +112,6 @@
|
||||
<field name="name">调岗</field>
|
||||
<field name="approval_type">generic_forms_reassignment</field>
|
||||
</record>
|
||||
|
||||
<!-- 天河投资办公室 - 通用离职表 -->
|
||||
<record id="approval_category_data_tianhe_dimission_approval" model="approval.category">
|
||||
<field name="name">离职表</field>
|
||||
<field name="approval_type">dimission</field>
|
||||
</record>
|
||||
<!-- 天河投资办公室 - 试用期转正式员工 -->
|
||||
<record id="approval_category_data_tianhe_tryout_official_approval" model="approval.category">
|
||||
<field name="name">试用期转正式员工</field>
|
||||
@@ -148,11 +122,6 @@
|
||||
<field name="name">实习期转试用期</field>
|
||||
<field name="approval_type">exercitation_tryout</field>
|
||||
</record>
|
||||
<!-- 天河投资办公室 - 党支委发文 -->
|
||||
<record id="approval_category_data_tianhe_party_branch_committee_post" model="approval.category">
|
||||
<field name="name">党支委发文</field>
|
||||
<field name="approval_type">party_branch_committee_post</field>
|
||||
</record>
|
||||
<!-- 天河投资办公室 - 调班登记表 -->
|
||||
<record id="approval_category_data_tianhe_shift_registration_form" model="approval.category">
|
||||
<field name="name">调班登记表</field>
|
||||
@@ -203,10 +172,10 @@
|
||||
<field name="name">业务呈批表(粮库专用)</field>
|
||||
<field name="approval_type">liangchu_business_approval_form_grain_depot</field>
|
||||
</record>
|
||||
<!-- 粮储 - 总经理办公会议议题呈批表 -->
|
||||
<!-- 议题申报表 -->
|
||||
<record id="approval_category_data_liangchu_general_manager_office_meeting_topics" model="approval.category">
|
||||
<field name="name">总经理办公会议议题呈批表</field>
|
||||
<field name="approval_type">liangchu_general_manager_office_meeting_topics</field>
|
||||
<field name="name">议题申报表</field>
|
||||
<field name="approval_type">issue_library_approval</field>
|
||||
</record>
|
||||
<!-- 天河物管 - 产权变更表申请表 -->
|
||||
<record id="approval_category_data_wuguan_property_right_alter_approval" model="approval.category">
|
||||
@@ -216,7 +185,7 @@
|
||||
<!-- 天河物管 - 请假登记单 -->
|
||||
<record id="approval_category_data_wuguan_leave_registration_approval" model="approval.category">
|
||||
<field name="name">请假登记单</field>
|
||||
<field name="approval_type">leave_registration</field>
|
||||
<field name="approval_type">holiday_approval</field>
|
||||
</record>
|
||||
<!-- 天河物管 - 物业管理变更申请表 -->
|
||||
<record id="approval_category_data_wuguan_property_management_application" model="approval.category">
|
||||
@@ -243,11 +212,6 @@
|
||||
<field name="name">跨部门协同</field>
|
||||
<field name="approval_type">departmental_collaboration</field>
|
||||
</record>
|
||||
<!-- 天河物管 - 收文呈批 -->
|
||||
<record id="approval_category_data_wuguan_receipt_submission" model="approval.category">
|
||||
<field name="name">收文呈批</field>
|
||||
<field name="approval_type">receipt_submission</field>
|
||||
</record>
|
||||
<!-- 天河投资办公室 - 费用调整申请表 -->
|
||||
<record id="approval_category_data_tianhe_fee_adjustment_approval" model="approval.category">
|
||||
<field name="name">费用调整申请表</field>
|
||||
@@ -258,22 +222,11 @@
|
||||
<field name="name">费用支出</field>
|
||||
<field name="approval_type">fee_expend</field>
|
||||
</record>
|
||||
<!-- 天河投资办公室 - 发文呈批 -->
|
||||
<record id="approval_category_data_tianhe_post_submission_approval" model="approval.category">
|
||||
<field name="name">发文呈批</field>
|
||||
<field name="approval_type">post_submission</field>
|
||||
</record>
|
||||
<!-- 天河投资办公室 - 督办工作表 -->
|
||||
<record id="approval_category_data_tianhe_supervision_worksheets_approval" model="approval.category">
|
||||
<field name="name">督办工作表</field>
|
||||
<field name="approval_type">supervision_worksheets</field>
|
||||
</record>
|
||||
<!-- 天河投资办公室 - 公众号/云短信发文呈批 -->
|
||||
<record id="approval_category_data_ttianhe_official_account_cloud_sms_post_submission_approval"
|
||||
model="approval.category">
|
||||
<field name="name">公众号/云短信发文呈批</field>
|
||||
<field name="approval_type">official_account_cloud_sms_post_submission</field>
|
||||
</record>
|
||||
<!-- 天河物管 - 用章申请 -->
|
||||
<record id="approval_category_data_wuguan_seal_application" model="approval.category">
|
||||
<field name="name">用章申请</field>
|
||||
|
||||
@@ -13,36 +13,39 @@ class ApprovalCategory(models.Model):
|
||||
('business_statement', '科创中小企业服务中心业务呈批表'),
|
||||
('process_change', '流程变更申请'),
|
||||
('contentious_matter', ' 诉讼事项录入表'),
|
||||
('issue_library_approval', '议题申报表'),
|
||||
('salary_approval', '工资呈批表'),
|
||||
('general_party_branch_post', '党总支发文'),
|
||||
('tianhe_dsh_issues_declarations', '董事会议题申报表'),
|
||||
('salary', '工资呈批表'),
|
||||
('party_branch_committee_post', '党支委发文'),
|
||||
('official_issues_for_son', '公文发文(子企业)'),
|
||||
('financing_post', '投融资发文'),
|
||||
('official_account_cloud_sms_post_submission', '公众号/云短信发文呈批'),
|
||||
('post_submission', '发文呈批'),
|
||||
('receipt_submission', '收文呈批'),
|
||||
('official_issues', '公文发文'),
|
||||
('official_receipt', '公文收文'),
|
||||
('meeting_file', '会议文档'),
|
||||
('financing_post', '投融资发文'),
|
||||
('business', '业务呈批表'),
|
||||
('property_management_application', '物业管理变更申请表'),
|
||||
('document_processing', '文件处理表'),
|
||||
('property_submission', '物业部业务呈批表'),
|
||||
('rental_receipt', '租赁合同收款单'),
|
||||
('generic_forms_replacement_card', '公司通用表单——补卡'),
|
||||
('generic_forms_overtime', '公司通用表单——加班'),
|
||||
('generic_forms_outing', '公司通用表单——外出'),
|
||||
('generic_forms_leave', '公司通用表单——请假'),
|
||||
('generic_forms_duty', '公司通用表单——值班'),
|
||||
('generic_forms_compensatory', '公司通用表单——调休'),
|
||||
('generic_forms_remove_holiday', '公司通用表单——销假'),
|
||||
('dimission', '离职表'),
|
||||
('card_approval', '补卡审批'),
|
||||
('overtime_approval', '加班审批'),
|
||||
('go_approval', '外出审批'),
|
||||
('holiday_approval', '请假审批'),
|
||||
('duty_approval', '值班审批'),
|
||||
('leave_approval', '调休审批'),
|
||||
('remove_holiday_approval', '销假审批'),
|
||||
('exit_form', '离职表'),
|
||||
('tryout_official', '试用期转正式员工'),
|
||||
('exercitation_tryout', '实习期转试用期'),
|
||||
('generic_forms_reassignment', '公司通用表单——调岗'),
|
||||
('party_branch_committee_post', '党支委发文'),
|
||||
('generic_forms_reassignment', '调岗'),
|
||||
('shift_registration_form', '调班登记表'),
|
||||
('meeting_minutes', '会议纪要'),
|
||||
('engineering', '工程立项'),
|
||||
('property_right_alter', '产权变更表申请表'),
|
||||
('leave_registration', '请假登记单'),
|
||||
('property_management_application', ' '),
|
||||
('tianhe_property_management_reporting', '物业经营报表管理'),
|
||||
('tianhe_fire_safety_management', '消防安全管理'),
|
||||
('tianhe_information_ublish_approval_form', '信息发布呈批表'),
|
||||
@@ -50,17 +53,13 @@ class ApprovalCategory(models.Model):
|
||||
('wuguan_intertubular_protocol', '管子间协议'),
|
||||
('wuguan_invoicing_information_approval_order', '开票信息审批单'),
|
||||
('liangchu_business_approval_form_grain_depot', '业务呈批表'),
|
||||
('liangchu_general_manager_office_meeting_topics', '总经理办公会议议题呈批表'),
|
||||
('liangchu_fixed_assets_purchase_order', '固定资产采购单'),
|
||||
('wuguan_information_release_approval_form', '信息发布审批表'),
|
||||
('construction_works', '建设工程审批单'),
|
||||
('departmental_collaboration', '跨部门协同'),
|
||||
('receipt_submission', '收文呈批'),
|
||||
('fee_adjustment', '费用调整申请表'),
|
||||
('post_submission', '发文呈批'),
|
||||
('fee_expend', '费用支出'),
|
||||
('supervision_worksheets', '督办工作表'),
|
||||
('official_account_cloud_sms_post_submission', '公众号/云短信发文呈批'),
|
||||
('wuguan_seal_application', '用章申请'),
|
||||
('wuguan_license_call_batch', '证照调用呈批'),
|
||||
('wuguan_lease_contract_approval_form', '租赁合同审批单'),
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
local.conf
|
||||
poetry.lock
|
||||
local-addons/*
|
||||
!local-addons/README.md
|
||||
!local-addons/__init__.py
|
||||
.project.yml
|
||||
.DS_Store
|
||||
.idea
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) [year] [fullname]
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,84 @@
|
||||
# xy_wechat
|
||||
适用于Odoo16版本的企业微信模块
|
||||
|
||||
<div>
|
||||
|
||||
[](https://github.com/xunonxyz/xy_wechat/blob/master/LICENSE.md)
|
||||
[](https://github.com/xunonxyz/xy_wechat/issues)
|
||||
[](https://github.com/odoo/odoo/tree/16.0)
|
||||
|
||||
</div>
|
||||
|
||||
## ✨ 特点
|
||||
|
||||
- 采用python升级特性协程进行同步,速度比多线程更快。同步50人公司组织架构只需10s左右
|
||||
- 仅inherit了少量model,避免多层inherit冗余嵌套影响加载性能
|
||||
- 支持Odoo多公司切换,每个公司可设置多个企业微信应用
|
||||
- 使用Hr模块为基础进行员工管理,而不是直接res.users,充分利用Odoo原生模块功能
|
||||
- 完整的i18n简中翻译,默认使用英文
|
||||
- 包含企业微信内建应用必备功能,去掉了其余冗余而普遍用不上的功能,减少模块大小
|
||||
|
||||
## 🖥 环境支持
|
||||
|
||||
- Odoo16版本
|
||||
- postgresql12及以上版本(Odoo16的hr模块中使用了Json字段,需要数据库有Json函数支持)
|
||||
- python3.7及以上(使用了协程特性)
|
||||
- 安装python的`aiohttp`和`pycrypto`库
|
||||
|
||||
## 📦 安装
|
||||
|
||||
在Odoo的应用里找到`Wechat Enterprise`模块激活即可安装,安装后模块名称翻译会被加载变成「企业微信」
|
||||
|
||||
## ✨ 包含功能
|
||||
|
||||
| 功能名称 | 是否支持 |
|
||||
| :-----------------------------------: | :------: |
|
||||
| 多公司用户切换 | √ |
|
||||
| 企业微信应用access_token获取并缓存 | √ |
|
||||
| WW_verify_xxx.txt域名校验文件一键配置 | √ |
|
||||
| 企业微信应用通讯录部门、成员一键同步 | √ |
|
||||
| 企业微信通讯录部门、成员变更回调 | √ |
|
||||
| 企业微信二维码扫码登录系统 | √ |
|
||||
| 企业微信网页跳转登录系统 | √ |
|
||||
| 向企业微信上传临时素材、永久图片 | √ |
|
||||
| 推送、撤回企业微信各类型消息 | √ |
|
||||
|
||||
## 🔨 使用
|
||||
|
||||
### 配置企业微信应用参数
|
||||
|
||||
在「Odoo系统 - 设置 - 用户&公司」页面,选中公司后切换到「企业微信应用」选项卡进行参数配置
|
||||
|
||||
注:如果勾选了应用中的「同步员工到res.user」则在保存hr.employee记录时也会创建res.users记录,否则仅创建hr.employee
|
||||
|
||||

|
||||
|
||||
### 同步
|
||||
|
||||
配置完参数后点击「同步组织架构」按钮即可。同步是在后端异步进行,不会阻塞当前操作,但尽量不要进行用户、部门的变更、创建、删除等操作。
|
||||
|
||||
同步完成或失败后均会有通知窗进行通知。
|
||||
|
||||

|
||||
|
||||
### 同步日志
|
||||
|
||||
每次同步的结果、报错代码栈、花费的时间等信息均可以进行查看。
|
||||
|
||||
在「员工 - 企业微信模块日志」页面可进行查看
|
||||
|
||||

|
||||
|
||||
## ❤️ 支持
|
||||
|
||||
富能通企业官网:https://www.funenc.com/
|
||||
|
||||
免费后端培训视频系列:https://www.bilibili.com/video/BV1rP411G7CZ/
|
||||
|
||||
知识星球Odoo源码解读:
|
||||
|
||||
<img src="./static/img/knowledge_star.png" style="width: 300px">
|
||||
|
||||
更多模块或进群交流欢迎加微信:
|
||||
|
||||
<img src="./static/img/wx_qrcode.png" style="width: 200px">
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import controllers
|
||||
from . import models
|
||||
@@ -0,0 +1,41 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': "Wechat Enterprise",
|
||||
|
||||
'summary': 'Wechat Enterprise',
|
||||
|
||||
'description': """
|
||||
Api and methods for Wechat Enterprise , which is used in Internal Enterprise Applications.
|
||||
Use pycrypto to replace Crypto.
|
||||
Need python version >= 3.7
|
||||
""",
|
||||
|
||||
'author': "funenc",
|
||||
'website': "https://www.funenc.com",
|
||||
|
||||
'external_dependencies': {
|
||||
'python': ['aiohttp', 'Crypto'],
|
||||
},
|
||||
|
||||
# Categories can be used to filter modules in modules listing
|
||||
# Check https://github.com/odoo/odoo/blob/15.0/odoo/addons/base/data/ir_module_category_data.xml
|
||||
# for the full list
|
||||
'category': 'Uncategorized',
|
||||
'version': '0.1',
|
||||
|
||||
# any module necessary for this one to work correctly
|
||||
'depends': ['base', 'hr'],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'wechat_enterprise/static/src/js/we_qrcode_widget.js',
|
||||
]
|
||||
},
|
||||
# always loaded
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/company.xml',
|
||||
'views/app.xml',
|
||||
'views/log.xml',
|
||||
],
|
||||
'application': True
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
from . import token_store
|
||||
from . import we_request
|
||||
from . import custom_encrypt
|
||||
from . import callback
|
||||
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- encoding:utf-8 -*-
|
||||
|
||||
""" 对企业微信发送给企业后台的消息加解密示例代码.
|
||||
@copyright: Copyright (c) 1998-2014 Tencent Inc.
|
||||
|
||||
"""
|
||||
# ------------------------------------------------------------------------
|
||||
import logging
|
||||
import base64
|
||||
import random
|
||||
import hashlib
|
||||
import time
|
||||
import struct
|
||||
from Crypto.Cipher import AES
|
||||
import xml.etree.cElementTree as ET
|
||||
import socket
|
||||
|
||||
from . import ierror
|
||||
|
||||
|
||||
"""
|
||||
关于Crypto.Cipher模块,ImportError: No module named 'Crypto'解决方案
|
||||
请到官方网站 https://www.dlitz.net/software/pycrypto/ 下载pycrypto。
|
||||
下载后,按照README中的“Installation”小节的提示进行pycrypto安装。
|
||||
"""
|
||||
|
||||
|
||||
class FormatException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def throw_exception(message, exception_class=FormatException):
|
||||
"""my define raise exception function"""
|
||||
raise exception_class(message)
|
||||
|
||||
|
||||
class SHA1:
|
||||
"""计算企业微信的消息签名接口"""
|
||||
|
||||
def getSHA1(self, token, timestamp, nonce, encrypt):
|
||||
"""用SHA1算法生成安全签名
|
||||
@param token: 票据
|
||||
@param timestamp: 时间戳
|
||||
@param encrypt: 密文
|
||||
@param nonce: 随机字符串
|
||||
@return: 安全签名
|
||||
"""
|
||||
try:
|
||||
sortlist = [token, timestamp, nonce, encrypt]
|
||||
sortlist.sort()
|
||||
sha = hashlib.sha1()
|
||||
sha.update("".join(sortlist).encode())
|
||||
return ierror.WXBizMsgCrypt_OK, sha.hexdigest()
|
||||
except Exception as e:
|
||||
logger = logging.getLogger()
|
||||
logger.error(e)
|
||||
return ierror.WXBizMsgCrypt_ComputeSignature_Error, None
|
||||
|
||||
|
||||
class XMLParse:
|
||||
"""提供提取消息格式中的密文及生成回复消息格式的接口"""
|
||||
|
||||
# xml消息模板
|
||||
AES_TEXT_RESPONSE_TEMPLATE = """<xml>
|
||||
<Encrypt><![CDATA[%(msg_encrypt)s]]></Encrypt>
|
||||
<MsgSignature><![CDATA[%(msg_signaturet)s]]></MsgSignature>
|
||||
<TimeStamp>%(timestamp)s</TimeStamp>
|
||||
<Nonce><![CDATA[%(nonce)s]]></Nonce>
|
||||
</xml>"""
|
||||
|
||||
def extract(self, xmltext):
|
||||
"""提取出xml数据包中的加密消息
|
||||
@param xmltext: 待提取的xml字符串
|
||||
@return: 提取出的加密消息字符串
|
||||
"""
|
||||
try:
|
||||
xml_tree = ET.fromstring(xmltext)
|
||||
encrypt = xml_tree.find("Encrypt")
|
||||
return ierror.WXBizMsgCrypt_OK, encrypt.text
|
||||
except Exception as e:
|
||||
logger = logging.getLogger()
|
||||
logger.error(e)
|
||||
return ierror.WXBizMsgCrypt_ParseXml_Error, None
|
||||
|
||||
def generate(self, encrypt, signature, timestamp, nonce):
|
||||
"""生成xml消息
|
||||
@param encrypt: 加密后的消息密文
|
||||
@param signature: 安全签名
|
||||
@param timestamp: 时间戳
|
||||
@param nonce: 随机字符串
|
||||
@return: 生成的xml字符串
|
||||
"""
|
||||
resp_dict = {
|
||||
'msg_encrypt': encrypt,
|
||||
'msg_signaturet': signature,
|
||||
'timestamp': timestamp,
|
||||
'nonce': nonce,
|
||||
}
|
||||
resp_xml = self.AES_TEXT_RESPONSE_TEMPLATE % resp_dict
|
||||
return resp_xml
|
||||
|
||||
|
||||
class PKCS7Encoder():
|
||||
"""提供基于PKCS7算法的加解密接口"""
|
||||
|
||||
block_size = 32
|
||||
|
||||
def encode(self, text):
|
||||
""" 对需要加密的明文进行填充补位
|
||||
@param text: 需要进行填充补位操作的明文
|
||||
@return: 补齐明文字符串
|
||||
"""
|
||||
text_length = len(text)
|
||||
# 计算需要填充的位数
|
||||
amount_to_pad = self.block_size - (text_length % self.block_size)
|
||||
if amount_to_pad == 0:
|
||||
amount_to_pad = self.block_size
|
||||
# 获得补位所用的字符
|
||||
pad = chr(amount_to_pad)
|
||||
return text + (pad * amount_to_pad).encode()
|
||||
|
||||
def decode(self, decrypted):
|
||||
"""删除解密后明文的补位字符
|
||||
@param decrypted: 解密后的明文
|
||||
@return: 删除补位字符后的明文
|
||||
"""
|
||||
pad = ord(decrypted[-1])
|
||||
if pad < 1 or pad > 32:
|
||||
pad = 0
|
||||
return decrypted[:-pad]
|
||||
|
||||
|
||||
class Prpcrypt(object):
|
||||
"""提供接收和推送给企业微信消息的加解密接口"""
|
||||
|
||||
def __init__(self, key):
|
||||
|
||||
# self.key = base64.b64decode(key+"=")
|
||||
self.key = key
|
||||
# 设置加解密模式为AES的CBC模式
|
||||
self.mode = AES.MODE_CBC
|
||||
|
||||
def encrypt(self, text, receiveid):
|
||||
"""对明文进行加密
|
||||
@param text: 需要加密的明文
|
||||
@return: 加密得到的字符串
|
||||
"""
|
||||
# 16位随机字符串添加到明文开头
|
||||
text = text.encode()
|
||||
text = self.get_random_str() + struct.pack("I", socket.htonl(len(text))) + text + receiveid.encode()
|
||||
|
||||
# 使用自定义的填充方式对明文进行补位填充
|
||||
pkcs7 = PKCS7Encoder()
|
||||
text = pkcs7.encode(text)
|
||||
# 加密
|
||||
cryptor = AES.new(self.key, self.mode, self.key[:16])
|
||||
try:
|
||||
ciphertext = cryptor.encrypt(text)
|
||||
# 使用BASE64对加密后的字符串进行编码
|
||||
return ierror.WXBizMsgCrypt_OK, base64.b64encode(ciphertext)
|
||||
except Exception as e:
|
||||
logger = logging.getLogger()
|
||||
logger.error(e)
|
||||
return ierror.WXBizMsgCrypt_EncryptAES_Error, None
|
||||
|
||||
def decrypt(self, text, receiveid):
|
||||
"""对解密后的明文进行补位删除
|
||||
@param text: 密文
|
||||
@return: 删除填充补位后的明文
|
||||
"""
|
||||
try:
|
||||
cryptor = AES.new(self.key, self.mode, self.key[:16])
|
||||
# 使用BASE64对密文进行解码,然后AES-CBC解密
|
||||
plain_text = cryptor.decrypt(base64.b64decode(text))
|
||||
except Exception as e:
|
||||
logger = logging.getLogger()
|
||||
logger.error(e)
|
||||
return ierror.WXBizMsgCrypt_DecryptAES_Error, None
|
||||
try:
|
||||
pad = plain_text[-1]
|
||||
# 去掉补位字符串
|
||||
# pkcs7 = PKCS7Encoder()
|
||||
# plain_text = pkcs7.encode(plain_text)
|
||||
# 去除16位随机字符串
|
||||
content = plain_text[16:-pad]
|
||||
xml_len = socket.ntohl(struct.unpack("I", content[: 4])[0])
|
||||
xml_content = content[4: xml_len + 4]
|
||||
from_receiveid = content[xml_len + 4:]
|
||||
except Exception as e:
|
||||
logger = logging.getLogger()
|
||||
logger.error(e)
|
||||
return ierror.WXBizMsgCrypt_IllegalBuffer, None
|
||||
|
||||
if from_receiveid.decode('utf8') != receiveid:
|
||||
return ierror.WXBizMsgCrypt_ValidateCorpid_Error, None
|
||||
return 0, xml_content
|
||||
|
||||
def get_random_str(self):
|
||||
""" 随机生成16位字符串
|
||||
@return: 16位字符串
|
||||
"""
|
||||
return str(random.randint(1000000000000000, 9999999999999999)).encode()
|
||||
|
||||
|
||||
class WXBizMsgCrypt(object):
|
||||
# 构造函数
|
||||
def __init__(self, sToken, sEncodingAESKey, sReceiveId):
|
||||
try:
|
||||
self.key = base64.b64decode(sEncodingAESKey + "=")
|
||||
assert len(self.key) == 32
|
||||
except:
|
||||
throw_exception("[error]: EncodingAESKey unvalid !", FormatException)
|
||||
# return ierror.WXBizMsgCrypt_IllegalAesKey,None
|
||||
self.m_sToken = sToken
|
||||
self.m_sReceiveId = sReceiveId
|
||||
|
||||
# 验证URL
|
||||
# @param sMsgSignature: 签名串,对应URL参数的msg_signature
|
||||
# @param sTimeStamp: 时间戳,对应URL参数的timestamp
|
||||
# @param sNonce: 随机串,对应URL参数的nonce
|
||||
# @param sEchoStr: 随机串,对应URL参数的echostr
|
||||
# @param sReplyEchoStr: 解密之后的echostr,当return返回0时有效
|
||||
# @return:成功0,失败返回对应的错误码
|
||||
|
||||
def VerifyURL(self, sMsgSignature, sTimeStamp, sNonce, sEchoStr):
|
||||
sha1 = SHA1()
|
||||
ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, sEchoStr)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
if not signature == sMsgSignature:
|
||||
return ierror.WXBizMsgCrypt_ValidateSignature_Error, None
|
||||
pc = Prpcrypt(self.key)
|
||||
ret, sReplyEchoStr = pc.decrypt(sEchoStr, self.m_sReceiveId)
|
||||
return ret, sReplyEchoStr
|
||||
|
||||
def EncryptMsg(self, sReplyMsg, sNonce, timestamp=None):
|
||||
# 将企业回复用户的消息加密打包
|
||||
# @param sReplyMsg: 企业号待回复用户的消息,xml格式的字符串
|
||||
# @param sTimeStamp: 时间戳,可以自己生成,也可以用URL参数的timestamp,如为None则自动用当前时间
|
||||
# @param sNonce: 随机串,可以自己生成,也可以用URL参数的nonce
|
||||
# sEncryptMsg: 加密后的可以直接回复用户的密文,包括msg_signature, timestamp, nonce, encrypt的xml格式的字符串,
|
||||
# return:成功0,sEncryptMsg,失败返回对应的错误码None
|
||||
pc = Prpcrypt(self.key)
|
||||
ret, encrypt = pc.encrypt(sReplyMsg, self.m_sReceiveId)
|
||||
encrypt = encrypt.decode('utf8')
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
if timestamp is None:
|
||||
timestamp = str(int(time.time()))
|
||||
# 生成安全签名
|
||||
sha1 = SHA1()
|
||||
ret, signature = sha1.getSHA1(self.m_sToken, timestamp, sNonce, encrypt)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
xmlParse = XMLParse()
|
||||
return ret, xmlParse.generate(encrypt, signature, timestamp, sNonce)
|
||||
|
||||
def DecryptMsg(self, sPostData, sMsgSignature, sTimeStamp, sNonce):
|
||||
# 检验消息的真实性,并且获取解密后的明文
|
||||
# @param sMsgSignature: 签名串,对应URL参数的msg_signature
|
||||
# @param sTimeStamp: 时间戳,对应URL参数的timestamp
|
||||
# @param sNonce: 随机串,对应URL参数的nonce
|
||||
# @param sPostData: 密文,对应POST请求的数据
|
||||
# xml_content: 解密后的原文,当return返回0时有效
|
||||
# @return: 成功0,失败返回对应的错误码
|
||||
# 验证安全签名
|
||||
xmlParse = XMLParse()
|
||||
ret, encrypt = xmlParse.extract(sPostData)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
sha1 = SHA1()
|
||||
ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, encrypt)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
if not signature == sMsgSignature:
|
||||
return ierror.WXBizMsgCrypt_ValidateSignature_Error, None
|
||||
pc = Prpcrypt(self.key)
|
||||
ret, xml_content = pc.decrypt(encrypt, self.m_sReceiveId)
|
||||
return ret, xml_content
|
||||
@@ -0,0 +1,2 @@
|
||||
from . import ierror
|
||||
from . import WXBizMsgCrypt
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#########################################################################
|
||||
# Author: jonyqin
|
||||
# Created Time: Thu 11 Sep 2014 01:53:58 PM CST
|
||||
# File Name: ierror.py
|
||||
# Description:定义错误码含义
|
||||
#########################################################################
|
||||
WXBizMsgCrypt_OK = 0
|
||||
WXBizMsgCrypt_ValidateSignature_Error = -40001
|
||||
WXBizMsgCrypt_ParseXml_Error = -40002
|
||||
WXBizMsgCrypt_ComputeSignature_Error = -40003
|
||||
WXBizMsgCrypt_IllegalAesKey = -40004
|
||||
WXBizMsgCrypt_ValidateCorpid_Error = -40005
|
||||
WXBizMsgCrypt_EncryptAES_Error = -40006
|
||||
WXBizMsgCrypt_DecryptAES_Error = -40007
|
||||
WXBizMsgCrypt_IllegalBuffer = -40008
|
||||
WXBizMsgCrypt_EncodeBase64_Error = -40009
|
||||
WXBizMsgCrypt_DecodeBase64_Error = -40010
|
||||
WXBizMsgCrypt_GenReturnXml_Error = -40011
|
||||
@@ -0,0 +1,28 @@
|
||||
import hashlib
|
||||
import random
|
||||
|
||||
salt_list = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
|
||||
def get_random_str():
|
||||
salt = ''
|
||||
for i in range(16):
|
||||
salt += random.choice(salt_list)
|
||||
return salt
|
||||
|
||||
|
||||
class CustomEncrypt(object):
|
||||
"""
|
||||
custom encrypt, use md5 to encrypt password, and add salt to password. has not decrypt function
|
||||
"""
|
||||
salt = get_random_str()
|
||||
|
||||
@staticmethod
|
||||
def encrypt(password):
|
||||
obj = hashlib.md5(CustomEncrypt.salt.encode("utf-8"))
|
||||
obj.update(password.encode('utf-8'))
|
||||
return obj.hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def is_equal(password, encode_password):
|
||||
return CustomEncrypt.encrypt(password) == encode_password
|
||||
@@ -0,0 +1,70 @@
|
||||
import time
|
||||
|
||||
token_temp = {}
|
||||
|
||||
|
||||
class TokenStore(object):
|
||||
def __init__(self, secret):
|
||||
"""
|
||||
secret is used to distinguish different WE apps
|
||||
:param secret:
|
||||
"""
|
||||
self.secret = secret
|
||||
|
||||
def save(self, token, expires_in, create_time=None):
|
||||
"""
|
||||
save token
|
||||
:param token: token
|
||||
:param expires_in: how long the token is valid, unit is second
|
||||
:param create_time: if create_time is None, use current time
|
||||
:return:
|
||||
"""
|
||||
current_time = time.time()
|
||||
token_temp[self.secret] = {
|
||||
'token': token,
|
||||
'expires_in': expires_in,
|
||||
'update_time': current_time,
|
||||
'create_time': create_time or current_time
|
||||
}
|
||||
|
||||
def get(self):
|
||||
"""
|
||||
if token is expires, clear it and return None
|
||||
:return:
|
||||
"""
|
||||
if self.secret in token_temp:
|
||||
token = token_temp[self.secret]
|
||||
if time.time() - token['update_time'] > token['expires_in']:
|
||||
token_temp.pop(self.secret)
|
||||
return None
|
||||
return token['token']
|
||||
return None
|
||||
|
||||
def refresh(self, expires_in):
|
||||
"""
|
||||
refresh token if it exists
|
||||
:param expires_in: how long the token is valid, unit is second
|
||||
:return:
|
||||
"""
|
||||
if self.secret in token_temp:
|
||||
token_dict = token_temp[self.secret]
|
||||
self.save(token_dict['token'], expires_in, token_dict['create_time'])
|
||||
|
||||
@staticmethod
|
||||
def clean(secret):
|
||||
"""
|
||||
clean token by secret
|
||||
:param secret: string
|
||||
:return:
|
||||
"""
|
||||
if secret in token_temp:
|
||||
token_temp.pop(secret)
|
||||
|
||||
@staticmethod
|
||||
def clean_all():
|
||||
"""
|
||||
clean all token
|
||||
:return:
|
||||
"""
|
||||
for key in token_temp:
|
||||
token_temp.pop(key)
|
||||
@@ -0,0 +1,228 @@
|
||||
import aiohttp
|
||||
from urllib import parse
|
||||
|
||||
from .token_store import TokenStore
|
||||
|
||||
|
||||
def check_response_error(response, error_code=0, error_msg_key='errmsg'):
|
||||
if response['errcode'] != error_code:
|
||||
raise Exception(response[error_msg_key])
|
||||
|
||||
|
||||
def join_url(base_url, *args):
|
||||
if not args:
|
||||
return base_url
|
||||
return parse.urljoin(base_url, ''.join(args))
|
||||
|
||||
|
||||
class WeRequest(object):
|
||||
url_prefix = 'https://qyapi.weixin.qq.com/cgi-bin/'
|
||||
|
||||
def __init__(self, corp_id, secret):
|
||||
"""
|
||||
set WE corp_id and secret
|
||||
:param corp_id: WE app corp_id
|
||||
:param secret: WE app secret
|
||||
"""
|
||||
self.corp_id = corp_id
|
||||
self.secret = secret
|
||||
self.token_store = TokenStore(secret)
|
||||
|
||||
async def refresh_token(self):
|
||||
"""
|
||||
refresh token if it expires
|
||||
:return:
|
||||
"""
|
||||
current_token = self.token_store.get()
|
||||
if not current_token:
|
||||
token = await self.get_token()
|
||||
self.token_store.save(token['token'], token['expires_in'])
|
||||
|
||||
async def latest_token(self):
|
||||
"""
|
||||
get latest token
|
||||
:return:
|
||||
"""
|
||||
await self.refresh_token()
|
||||
return self.token_store.get()
|
||||
|
||||
@staticmethod
|
||||
async def get_response(url, params=None, response_callback=None):
|
||||
"""
|
||||
get response from server
|
||||
:param url: url join with url_prefix
|
||||
:param params:
|
||||
:param response_callback: response callback function
|
||||
:return:
|
||||
"""
|
||||
conn = aiohttp.TCPConnector(ssl=False)
|
||||
async with aiohttp.ClientSession(connector=conn) as session:
|
||||
async with session.get(url, params=params) as response:
|
||||
return await response_callback(response) if response_callback else await response.json()
|
||||
|
||||
@staticmethod
|
||||
async def post_response(url, json, data=None, response_callback=None):
|
||||
"""
|
||||
post response to server, if json is not None, use json, else use data
|
||||
:param url: url join with url_prefix
|
||||
:param data: json data
|
||||
:param json: form data
|
||||
:param response_callback: response callback function
|
||||
:return:
|
||||
"""
|
||||
conn = aiohttp.TCPConnector(ssl=False)
|
||||
async with aiohttp.ClientSession(connector=conn) as session:
|
||||
async with session.post(url, json=json, data=data) as response:
|
||||
return await response_callback(response) if response_callback else await response.json()
|
||||
|
||||
async def get_token(self):
|
||||
"""
|
||||
get token from server
|
||||
:return:
|
||||
"""
|
||||
response = await self.get_response(join_url(self.url_prefix, 'gettoken'), {
|
||||
'corpid': self.corp_id,
|
||||
'corpsecret': self.secret
|
||||
})
|
||||
check_response_error(response)
|
||||
return {
|
||||
'token': response['access_token'],
|
||||
'expires_in': response['expires_in']
|
||||
}
|
||||
|
||||
async def department_simplelist(self, id=None):
|
||||
"""
|
||||
get department simplelist from server
|
||||
:param id: parent department id, if None, get all department
|
||||
:return:
|
||||
"""
|
||||
response = await self.get_response(join_url(self.url_prefix, 'department/simplelist'), {
|
||||
'access_token': await self.latest_token(),
|
||||
'id': id or ''
|
||||
})
|
||||
check_response_error(response)
|
||||
return response['department_id']
|
||||
|
||||
async def department_detail(self, id):
|
||||
"""
|
||||
get department detail from server
|
||||
:param id: department id, required
|
||||
:return:
|
||||
"""
|
||||
assert id, 'id is required'
|
||||
response = await self.get_response(join_url(self.url_prefix, 'department/get'), {
|
||||
'access_token': await self.latest_token(),
|
||||
'id': id
|
||||
})
|
||||
check_response_error(response)
|
||||
return response['department']
|
||||
|
||||
async def department_users(self, dep_id):
|
||||
"""
|
||||
get department users from server
|
||||
:param dep_id: department id, required
|
||||
:return:
|
||||
"""
|
||||
assert dep_id, 'dep_id is required'
|
||||
response = await self.get_response(join_url(self.url_prefix, 'user/list'), {
|
||||
'access_token': await self.latest_token(),
|
||||
'department_id': dep_id
|
||||
})
|
||||
check_response_error(response)
|
||||
return response['userlist']
|
||||
|
||||
async def get_userid(self, code):
|
||||
"""
|
||||
get userid from server
|
||||
:param code: user code by scan qrcode
|
||||
:return:
|
||||
"""
|
||||
assert code, 'code is required'
|
||||
response = await self.get_response(join_url(self.url_prefix, 'auth/getuserinfo'), {
|
||||
'access_token': await self.latest_token(),
|
||||
'code': code
|
||||
})
|
||||
check_response_error(response)
|
||||
return response.get('userid', None) or response.get('openid', None)
|
||||
|
||||
async def send_message(self, message):
|
||||
"""
|
||||
send message to user
|
||||
:param message: message dict
|
||||
:return:
|
||||
"""
|
||||
assert message and isinstance(message, dict), 'message is required and must be dict'
|
||||
response = await self.post_response(
|
||||
join_url(self.url_prefix, f'message/send?access_token={await self.latest_token()}'), message)
|
||||
check_response_error(response)
|
||||
return response.get('msgid', None)
|
||||
|
||||
async def recall_message(self, msg_id):
|
||||
"""
|
||||
recall message
|
||||
:param msg_id: message id
|
||||
:return:
|
||||
"""
|
||||
assert msg_id, 'msg_id is required'
|
||||
response = await self.post_response(
|
||||
join_url(self.url_prefix, f'message/recall?access_token={await self.latest_token()}'), {'msgid': msg_id})
|
||||
check_response_error(response)
|
||||
return response.get('errmsg', None)
|
||||
|
||||
async def upload_media(self, media_type, media_file, filename):
|
||||
"""
|
||||
upload temporary media to server, it only save 3 days, the file size has limit,
|
||||
reference: https://developer.work.weixin.qq.com/document/path/91054
|
||||
:param media_type: image, voice, video, file
|
||||
:param media_file: file content bytes
|
||||
:param filename: file name
|
||||
:return: media_id
|
||||
"""
|
||||
data = aiohttp.FormData()
|
||||
data.add_field('media', media_file, filename=filename, content_type='application/octet-stream')
|
||||
response = await self.post_response(
|
||||
join_url(self.url_prefix, f'media/upload?access_token={await self.latest_token()}&type={media_type}'),
|
||||
None, data)
|
||||
check_response_error(response)
|
||||
return response.get('media_id', None)
|
||||
|
||||
async def upload_image(self, media_file, filename):
|
||||
"""
|
||||
upload image to server, it can save permanently, the file size has limit,
|
||||
reference: https://developer.work.weixin.qq.com/document/path/91054
|
||||
:param media_file: file content bytes
|
||||
:param filename: file name
|
||||
:return: file url
|
||||
"""
|
||||
data = aiohttp.FormData()
|
||||
data.add_field(filename, media_file, filename=filename, content_type='application/octet-stream')
|
||||
response = await self.post_response(
|
||||
join_url(self.url_prefix, f'media/uploadimg?access_token={await self.latest_token()}'),
|
||||
None, data)
|
||||
check_response_error(response)
|
||||
return response.get('url', None)
|
||||
|
||||
async def get_media(self, media_id):
|
||||
"""
|
||||
get media from server
|
||||
:param media_id: media id
|
||||
:return: media content bytes
|
||||
"""
|
||||
async def _cb(res):
|
||||
return await res.read()
|
||||
|
||||
response = await self.get_response(join_url(self.url_prefix, f'media/get'), {
|
||||
'access_token': await self.latest_token(),
|
||||
'media_id': media_id
|
||||
}, response_callback=_cb)
|
||||
return response
|
||||
|
||||
|
||||
def we_request_instance(corp_id, secret):
|
||||
"""
|
||||
if you want to use custom WeRequest class or Store class, you can set monkey patch to this function
|
||||
:param corp_id:
|
||||
:param secret:
|
||||
:return:
|
||||
"""
|
||||
return WeRequest(corp_id, secret)
|
||||
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import controllers
|
||||
@@ -0,0 +1,145 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import asyncio
|
||||
import base64
|
||||
from urllib import parse
|
||||
import warnings
|
||||
import xml.etree.cElementTree as ET
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import route, request
|
||||
from ..common.we_request import we_request_instance, join_url
|
||||
from ..models.res_users import ResUsers
|
||||
from ..common.custom_encrypt import CustomEncrypt
|
||||
from ..common.callback.WXBizMsgCrypt import WXBizMsgCrypt
|
||||
|
||||
# Crypto will show warning, ignore it
|
||||
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
||||
|
||||
|
||||
class WechatEnterprise(http.Controller):
|
||||
scan_oauth_url = 'https://open.work.weixin.qq.com/wwopen/sso/qrConnect?appid={corp_id}&agentid={agentid}&redirect_uri={redirect_uri}'
|
||||
web_oauth_url = 'https://open.weixin.qq.com/connect/oauth2/authorize?appid={corp_id}&redirect_uri={redirect_uri}&response_type=code&scope={scope}&agentid={agentid}#wechat_redirect'
|
||||
|
||||
@http.route('/WW_verify_<string:filename>.txt', type='http', auth="none", methods=['GET'])
|
||||
def load_file(self, filename=None):
|
||||
"""
|
||||
load verify file, easily to verify wechat enterprise redirect url
|
||||
:param filename:
|
||||
:return:
|
||||
"""
|
||||
app = request.env['wechat.enterprise.app'].sudo().search(
|
||||
[('verify_txt_filename', '=', f'WW_verify_{filename}.txt')],
|
||||
limit=1
|
||||
)
|
||||
if len(app) == 0:
|
||||
content = None
|
||||
else:
|
||||
content = base64.b64decode(app[0].verify_txt).decode('utf-8')
|
||||
return content
|
||||
|
||||
@route('/we/oauth2/info', type='json', auth='public')
|
||||
def get_we_oauth2_info(self, app_id, redirect_uri=None):
|
||||
"""
|
||||
get info for build Wechat Enterprise QRCode to login
|
||||
:param app_id:
|
||||
:param redirect_uri: redirect url after login, don't need host
|
||||
:return:
|
||||
"""
|
||||
app = request.env['wechat.enterprise.app'].sudo().browse(int(app_id))
|
||||
host = request.httprequest.host_url
|
||||
return {
|
||||
'agentid': app.agentid,
|
||||
'corp_id': app.corp_id,
|
||||
'redirect_uri': join_url(host, f'we/oauth2/login/{app_id}') if redirect_uri is None else
|
||||
join_url(host, redirect_uri)
|
||||
}
|
||||
|
||||
@route('/we/oauth2/login/<int:app_id>', type='http', auth='public')
|
||||
def login_by_oauth2(self, code, app_id):
|
||||
"""
|
||||
login by wechat enterprise oauth2
|
||||
:param code:
|
||||
:param app_id:
|
||||
:return:
|
||||
"""
|
||||
app = request.env['wechat.enterprise.app'].sudo().browse(int(app_id))
|
||||
we_request = we_request_instance(app.corp_id, app.secret)
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
get_userid_task = loop.create_task(we_request.get_userid(code))
|
||||
loop.run_until_complete(get_userid_task)
|
||||
loop.close()
|
||||
we_id = get_userid_task.result()
|
||||
employee = request.env['hr.employee'].sudo().search([('we_id', '=', we_id)])
|
||||
if employee.user_id.id:
|
||||
secret_dict = {
|
||||
'npa': True,
|
||||
'type': 'we',
|
||||
'password': CustomEncrypt.encrypt(ResUsers.we_auth_secret)
|
||||
}
|
||||
request.session.authenticate(request.session.db, employee.user_id.login, secret_dict)
|
||||
return request.redirect('/web')
|
||||
|
||||
@route('/we/oauth2/build/<string:oauth_type>/<int:app_id>', type='http', auth='public')
|
||||
def we_oauth2(self, oauth_type, app_id, scope='snsapi_base'):
|
||||
"""
|
||||
build wechat enterprise oauth2 url
|
||||
:param oauth_type: scan or web, scan is build to qrcode, web is build to redirect url
|
||||
:param app_id: authenticate used app id
|
||||
:param scope: snsapi_base or snsapi_privateinfo
|
||||
:return:
|
||||
"""
|
||||
oauth2_info = self.get_we_oauth2_info(app_id)
|
||||
corp_id = oauth2_info['corp_id']
|
||||
agentid = oauth2_info['agentid']
|
||||
redirect_uri = parse.quote(oauth2_info['redirect_uri'])
|
||||
|
||||
_redirect_uri = ''
|
||||
if oauth_type == 'scan':
|
||||
_redirect_uri = WechatEnterprise.scan_oauth_url.format(
|
||||
corp_id=corp_id,
|
||||
agentid=agentid,
|
||||
redirect_uri=redirect_uri
|
||||
)
|
||||
elif oauth_type == 'web':
|
||||
_redirect_uri = WechatEnterprise.web_oauth_url.format(
|
||||
corp_id=corp_id,
|
||||
agentid=agentid,
|
||||
redirect_uri=redirect_uri,
|
||||
scope=scope
|
||||
)
|
||||
return request.redirect(_redirect_uri, 303, False)
|
||||
|
||||
@route('/we/mail_list/callback', type='http', auth='public', csrf=False)
|
||||
def we_callback(self, msg_signature, timestamp, nonce, echostr=''):
|
||||
if request.httprequest.method == 'GET':
|
||||
result = None
|
||||
company_list = request.env['res.company'].sudo().search([])
|
||||
for company in company_list:
|
||||
if company.we_cb_token and company.we_cb_encoding_AES_key:
|
||||
wxcpt = WXBizMsgCrypt(company.we_cb_token, company.we_cb_encoding_AES_key, company.we_corp_id)
|
||||
ret, s_echo_str = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
|
||||
if ret == 0:
|
||||
result = s_echo_str
|
||||
break
|
||||
return result
|
||||
else:
|
||||
data = request.httprequest.data.decode('utf-8')
|
||||
# get ToUserName to match company corp_id
|
||||
temp_xml_tree = ET.fromstring(data)
|
||||
corp_id = temp_xml_tree.find('ToUserName').text
|
||||
company = request.env['res.company'].sudo().search([('we_corp_id', '=', corp_id)])
|
||||
|
||||
wxcpt = WXBizMsgCrypt(company.we_cb_token, company.we_cb_encoding_AES_key, company.we_corp_id)
|
||||
ret, result = wxcpt.DecryptMsg(data, msg_signature, timestamp, nonce)
|
||||
if ret == 0:
|
||||
xml_tree = ET.fromstring(result)
|
||||
change_type = xml_tree.find('ChangeType').text
|
||||
# change_type may be xxx_user or xxx_party
|
||||
suffix = change_type.split('_')[1]
|
||||
model = request.env['hr.employee'] if suffix == 'user' else request.env['hr.department']
|
||||
# method need return 'success' or empty string
|
||||
func = getattr(model, f'on_we_{change_type}', None)
|
||||
if func:
|
||||
return func(xml_tree, company)
|
||||
return ''
|
||||
@@ -0,0 +1,235 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * wechat_enterprise
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 16.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2022-11-08 09:36+0000\n"
|
||||
"PO-Revision-Date: 2022-11-08 09:36+0000\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#. module: base
|
||||
#: model:ir.module.module,shortdesc:base.module_wechat_enterprise
|
||||
#: model:ir.module.module,summary:base.module_wechat_enterprise
|
||||
msgid "Wechat Enterprise"
|
||||
msgstr "企业微信"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_wechat_enterprise_app__agentid
|
||||
msgid "Agent ID"
|
||||
msgstr "应用ID"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_res_company__app_ids
|
||||
msgid "Apps"
|
||||
msgstr "企业微信应用"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model_terms:ir.ui.view,arch_db:wechat_enterprise.we_company_extend
|
||||
msgid "Callback Settings"
|
||||
msgstr "通讯录回调设置"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model,name:wechat_enterprise.model_res_company
|
||||
msgid "Companies"
|
||||
msgstr "公司"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_wechat_enterprise_app__company_id
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_wechat_enterprise_log__company_id
|
||||
msgid "Company"
|
||||
msgstr "公司"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_res_company__we_corp_id
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_wechat_enterprise_app__corp_id
|
||||
msgid "Corp ID"
|
||||
msgstr "企业ID"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_wechat_enterprise_app__create_uid
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_wechat_enterprise_log__create_uid
|
||||
msgid "Created by"
|
||||
msgstr "创建人"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_wechat_enterprise_app__create_date
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_wechat_enterprise_log__create_date
|
||||
msgid "Created on"
|
||||
msgstr "创建时间"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model,name:wechat_enterprise.model_hr_department
|
||||
msgid "Department"
|
||||
msgstr "部门"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_wechat_enterprise_app__description
|
||||
msgid "Description"
|
||||
msgstr "说明"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_wechat_enterprise_log__detail
|
||||
msgid "Detail"
|
||||
msgstr "详情"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_wechat_enterprise_app__display_name
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_wechat_enterprise_log__display_name
|
||||
msgid "Display Name"
|
||||
msgstr "显示名称"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model,name:wechat_enterprise.model_hr_employee
|
||||
msgid "Employee"
|
||||
msgstr "员工"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_res_company__we_cb_encoding_AES_key
|
||||
msgid "EncodingAESKey"
|
||||
msgstr ""
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_wechat_enterprise_app__id
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_wechat_enterprise_log__id
|
||||
msgid "ID"
|
||||
msgstr ""
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_wechat_enterprise_app____last_update
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_wechat_enterprise_log____last_update
|
||||
msgid "Last Modified on"
|
||||
msgstr "最后修改时间"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_wechat_enterprise_app__write_uid
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_wechat_enterprise_log__write_uid
|
||||
msgid "Last Updated by"
|
||||
msgstr "最后更新人"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_wechat_enterprise_app__write_date
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_wechat_enterprise_log__write_date
|
||||
msgid "Last Updated on"
|
||||
msgstr "最后更新时间"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_wechat_enterprise_app__name
|
||||
msgid "Name"
|
||||
msgstr "名称"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: code:addons/wechat_enterprise/models/employee.py:0
|
||||
#, python-format
|
||||
msgid "Please select the user or department to send the message!"
|
||||
msgstr "请选择发送message的用户或部门!"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_wechat_enterprise_app__secret
|
||||
msgid "Secret"
|
||||
msgstr ""
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: code:addons/wechat_enterprise/models/app.py:0
|
||||
#, python-format
|
||||
msgid "Start sync organization now, please wait......"
|
||||
msgstr "开始同步组织架构,请稍候......"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model_terms:ir.ui.view,arch_db:wechat_enterprise.we_company_extend
|
||||
msgid "Sync Organization"
|
||||
msgstr "同步组织架构"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_wechat_enterprise_app__sync_with_user
|
||||
msgid "Sync with res.user"
|
||||
msgstr "同步员工到res.user"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_res_company__we_cb_token
|
||||
msgid "Token"
|
||||
msgstr ""
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model_terms:ir.ui.view,arch_db:wechat_enterprise.wechat_enterprise_app_form
|
||||
msgid "Url Verify Settings"
|
||||
msgstr "域名验证设置"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model,name:wechat_enterprise.model_res_users
|
||||
msgid "User"
|
||||
msgstr "用户"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_wechat_enterprise_app__verify_txt_filename
|
||||
msgid "Verify Txt Filename"
|
||||
msgstr "网页授权验证文件名"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model,name:wechat_enterprise.model_wechat_enterprise_app
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_wechat_enterprise_log__we_app_id
|
||||
msgid "Wechat Enterprise App"
|
||||
msgstr "企业微信应用"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model_terms:ir.ui.view,arch_db:wechat_enterprise.we_company_extend
|
||||
msgid "Wechat Enterprise Apps"
|
||||
msgstr "企业微信应用"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_hr_department__we_id
|
||||
msgid "Wechat Enterprise Department ID"
|
||||
msgstr "企业微信部门ID"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_hr_department__we_order
|
||||
msgid "Wechat Enterprise Department Order"
|
||||
msgstr "企业微信部门排序"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_hr_employee__we_department_ids
|
||||
msgid "Wechat Enterprise Departments"
|
||||
msgstr "企业微信部门"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_hr_department__we_employee_ids
|
||||
msgid "Wechat Enterprise Employees"
|
||||
msgstr "企业微信员工"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model,name:wechat_enterprise.model_wechat_enterprise_log
|
||||
msgid "Wechat Enterprise Log"
|
||||
msgstr "企业微信日志"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.actions.act_window,name:wechat_enterprise.wechat_enterprise_log_act_window
|
||||
#: model:ir.ui.menu,name:wechat_enterprise.menu_wechat_enterprise_log
|
||||
msgid "Wechat Enterprise Logs"
|
||||
msgstr "企业微信模块日志"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_hr_department__we_parent_id
|
||||
msgid "Wechat Enterprise Parent Department ID"
|
||||
msgstr "企业微信父部门ID"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_hr_employee__we_extattr
|
||||
msgid "Wechat Enterprise User Extattr"
|
||||
msgstr "企业微信用户扩展属性"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_hr_employee__we_id
|
||||
msgid "Wechat Enterprise User ID"
|
||||
msgstr "企业微信用户ID"
|
||||
|
||||
#. module: wechat_enterprise
|
||||
#: model:ir.model.fields,field_description:wechat_enterprise.field_wechat_enterprise_app__verify_txt
|
||||
msgid "verify_txt"
|
||||
msgstr "网页授权验证文件"
|
||||
@@ -0,0 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import app
|
||||
from . import company
|
||||
from . import department
|
||||
from . import employee
|
||||
from . import log
|
||||
from . import res_users
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
import datetime
|
||||
import traceback
|
||||
|
||||
from odoo import models, fields, api
|
||||
from odoo.tools.translate import _
|
||||
|
||||
from ..common.we_request import we_request_instance
|
||||
|
||||
|
||||
def get_now_time_str():
|
||||
return datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
|
||||
class App(models.Model):
|
||||
_name = 'wechat.enterprise.app'
|
||||
_description = 'Wechat Enterprise App'
|
||||
|
||||
name = fields.Char(string='Name', required=True)
|
||||
description = fields.Text(string='Description')
|
||||
corp_id = fields.Char(string='Corp ID', related='company_id.we_corp_id', readonly=True)
|
||||
secret = fields.Char(string='Secret', required=True)
|
||||
agentid = fields.Char(string='Agent ID', required=True)
|
||||
sync_with_user = fields.Boolean(string='Sync with res.user', default=True)
|
||||
company_id = fields.Many2one('res.company', string='Company', required=True)
|
||||
# use in verify url
|
||||
verify_txt_filename = fields.Char(string='Verify Txt Filename', readonly=True)
|
||||
verify_txt = fields.Binary('verify_txt')
|
||||
|
||||
def run_sync(self):
|
||||
self.env['bus.bus']._sendone(self.env.user.partner_id, 'simple_notification', {
|
||||
'title': 'Sync Start......',
|
||||
'message': _('Start sync organization now, please wait......'),
|
||||
'warning': True
|
||||
})
|
||||
|
||||
# create a threading to avoid odoo ui blocking
|
||||
def _sync():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
asyncio.run(self.sync_organization())
|
||||
|
||||
thread = threading.Thread(target=_sync)
|
||||
thread.start()
|
||||
|
||||
async def sync_organization(self):
|
||||
start = time.time()
|
||||
uid = self.env.uid
|
||||
is_success = True
|
||||
with self.env.registry.cursor() as new_cr:
|
||||
self.env = api.Environment(new_cr, uid, self.env.context)
|
||||
|
||||
detail_log = f'start sync at {get_now_time_str()}......'
|
||||
try:
|
||||
we_request = we_request_instance(self.corp_id, self.secret)
|
||||
|
||||
await self.env['hr.department'].with_context(
|
||||
self.env.context, we_app=self, we_request=we_request
|
||||
).sync_department()
|
||||
detail_log += f'\nsync success!'
|
||||
except Exception:
|
||||
is_success = False
|
||||
detail_log += f'\nsync failed, error: \n{traceback.format_exc()}'
|
||||
finally:
|
||||
detail_log += f'\nsync end at {get_now_time_str()}, cost {round(time.time() - start, 2)}s'
|
||||
company_id = self.company_id.id
|
||||
self.env['wechat.enterprise.log'].create({
|
||||
'company_id': company_id,
|
||||
'we_app_id': self.id,
|
||||
'detail': detail_log
|
||||
})
|
||||
self.env['bus.bus']._sendone(self.env.user.partner_id, 'simple_notification', {
|
||||
'title': 'Sync End......',
|
||||
'message': f'Sync organization end, {"success" if is_success else "failed"}',
|
||||
'warning': True if is_success else False
|
||||
})
|
||||
|
||||
def upload_media(self, app_id, media_type, file_content, filename):
|
||||
"""
|
||||
upload temp media to wechat server
|
||||
:param app_id: save at which app server
|
||||
:param media_type: image, voice, video, file
|
||||
:param file_content: file content bytes
|
||||
:param filename: file name
|
||||
:return: media_id in wechat server
|
||||
"""
|
||||
app = self.env['wechat.enterprise.app'].sudo().browse(int(app_id))
|
||||
we_request = we_request_instance(app.corp_id, app.secret)
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
upload_media_task = loop.create_task(we_request.upload_media(media_type, file_content, filename))
|
||||
loop.run_until_complete(upload_media_task)
|
||||
loop.close()
|
||||
return upload_media_task.result()
|
||||
|
||||
def upload_image(self, app_id, file_content, filename):
|
||||
"""
|
||||
upload permanently image to wechat server
|
||||
:param app_id: save at which app server
|
||||
:param file_content: file content bytes
|
||||
:param filename: file name
|
||||
:return: file_url in wechat server
|
||||
"""
|
||||
app = self.env['wechat.enterprise.app'].sudo().browse(int(app_id))
|
||||
we_request = we_request_instance(app.corp_id, app.secret)
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
upload_img_task = loop.create_task(we_request.upload_image(file_content, filename))
|
||||
loop.run_until_complete(upload_img_task)
|
||||
loop.close()
|
||||
return upload_img_task.result()
|
||||
|
||||
def get_media(self, app_id, media_id):
|
||||
"""
|
||||
get media from wechat server
|
||||
:param app_id: save in which app server
|
||||
:param media_id: media id in wechat server
|
||||
:return: file content bytes
|
||||
"""
|
||||
app = self.env['wechat.enterprise.app'].sudo().browse(int(app_id))
|
||||
we_request = we_request_instance(app.corp_id, app.secret)
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
get_media_task = loop.create_task(we_request.get_media(media_id))
|
||||
loop.run_until_complete(get_media_task)
|
||||
loop.close()
|
||||
return get_media_task.result()
|
||||
@@ -0,0 +1,11 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class Company(models.Model):
|
||||
_inherit = 'res.company'
|
||||
|
||||
we_corp_id = fields.Char(string='Corp ID', required=True, default='Wechat Enterprise corp id')
|
||||
app_ids = fields.One2many('wechat.enterprise.app', 'company_id', string='Apps')
|
||||
# use in WE callback
|
||||
we_cb_token = fields.Char(string='Token')
|
||||
we_cb_encoding_AES_key = fields.Char(string='EncodingAESKey')
|
||||
@@ -0,0 +1,137 @@
|
||||
import asyncio
|
||||
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class Department(models.Model):
|
||||
_inherit = 'hr.department'
|
||||
|
||||
we_id = fields.Char(string='Wechat Enterprise Department ID')
|
||||
we_parent_id = fields.Char(string='Wechat Enterprise Parent Department ID')
|
||||
we_order = fields.Integer(string='Wechat Enterprise Department Order')
|
||||
'''because a Wechat Enterprise user can have multi departments, so we need a many2many field,
|
||||
the department_id field is only main department'''
|
||||
we_employee_ids = fields.Many2many('hr.employee', 'we_employee_department_rel', 'department_id', 'employee_id',
|
||||
string='Wechat Enterprise Employees')
|
||||
|
||||
async def get_server_depart_tree(self):
|
||||
"""
|
||||
get Wechat Enterprise server department tree, root parentid is 1
|
||||
:return:
|
||||
"""
|
||||
we_request = self.env.context.get('we_request')
|
||||
departments = await we_request.department_simplelist()
|
||||
|
||||
def get_tree_data(arr, parent_id):
|
||||
tree = []
|
||||
for item in arr:
|
||||
if item['parentid'] == parent_id:
|
||||
tree.append({
|
||||
'id': item['id'],
|
||||
'parentid': item['parentid'],
|
||||
'order': item['order'],
|
||||
'children': get_tree_data(arr, item['id'])
|
||||
})
|
||||
|
||||
return tree
|
||||
|
||||
return get_tree_data(departments, 1)
|
||||
|
||||
async def sync_department(self):
|
||||
"""
|
||||
sync department from wechat enterprise
|
||||
:return:
|
||||
"""
|
||||
we_request = self.env.context.get('we_request')
|
||||
we_app = self.env.context.get('we_app')
|
||||
|
||||
depart_tree = await self.get_server_depart_tree()
|
||||
|
||||
tasks = []
|
||||
|
||||
async def _sync_dep(server_dep, parent_id):
|
||||
_tasks = []
|
||||
dep_detail = await we_request.department_detail(server_dep['id'])
|
||||
|
||||
dep = self.search([('we_id', '=', dep_detail['id'])])
|
||||
# dep need commit to db because sync user need use it
|
||||
modify_data = {
|
||||
'company_id': we_app.company_id.id,
|
||||
'name': dep_detail['name'],
|
||||
'we_id': dep_detail['id'],
|
||||
'parent_id': parent_id,
|
||||
'we_parent_id': dep_detail['parentid'],
|
||||
'we_order': dep_detail['order'],
|
||||
'manager_id': False
|
||||
}
|
||||
|
||||
if dep.id is False:
|
||||
dep = self.create(modify_data)
|
||||
else:
|
||||
# change where the employee in the department status to active = False
|
||||
self.env['hr.employee'].search([('we_department_ids.we_id', '=', dep_detail['id'])]).write(
|
||||
{'active': False})
|
||||
dep.write(modify_data)
|
||||
|
||||
await self.env['hr.employee'].sync_user(dep, dep_detail['id'])
|
||||
|
||||
if len(server_dep['children']) > 0:
|
||||
for child in server_dep['children']:
|
||||
_tasks.append(_sync_dep(child, dep.id))
|
||||
await asyncio.gather(*_tasks)
|
||||
|
||||
for department in depart_tree:
|
||||
tasks.append(_sync_dep(department, False))
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
def on_we_create_party(self, xml_tree, company):
|
||||
"""
|
||||
on wechat enterprise create department event, only has Id and ParentId, no Name or Other fields
|
||||
:param xml_tree: ET.fromstring(str) instance
|
||||
:param company: receive company
|
||||
:return:
|
||||
"""
|
||||
we_id = xml_tree.find('Id').text
|
||||
we_parent_id = xml_tree.find('ParentId').text
|
||||
parent_dep = self.sudo().search([('we_id', '=', we_parent_id), ('company_id', '=', company.id)])
|
||||
self.sudo().create({
|
||||
'company_id': company.id,
|
||||
'name': f'we_id_{we_id}',
|
||||
'we_id': we_id,
|
||||
'parent_id': parent_dep.id,
|
||||
'we_parent_id': we_parent_id,
|
||||
'manager_id': False
|
||||
})
|
||||
return 'success'
|
||||
|
||||
def on_we_update_party(self, xml_tree, company):
|
||||
"""
|
||||
on wechat enterprise update department event, only has Id and ParentId, no Name or Other fields
|
||||
:param xml_tree: ET.fromstring(str) instance
|
||||
:param company: receive company
|
||||
:return:
|
||||
"""
|
||||
we_id = xml_tree.find('Id').text
|
||||
we_parent_id = xml_tree.find('ParentId').text
|
||||
dep = self.sudo().search([('we_id', '=', we_id), ('company_id', '=', company.id)])
|
||||
if dep.id is not False:
|
||||
parent_dep = self.sudo().search([('we_id', '=', we_parent_id), ('company_id', '=', company.id)])
|
||||
dep.write({
|
||||
'parent_id': parent_dep.id,
|
||||
'we_parent_id': we_parent_id
|
||||
})
|
||||
return 'success'
|
||||
|
||||
def on_we_delete_party(self, xml_tree, company):
|
||||
"""
|
||||
on wechat enterprise delete department event, only has Id and ParentId, no Name or Other fields
|
||||
:param xml_tree: ET.fromstring(str) instance
|
||||
:param company: receive company
|
||||
:return:
|
||||
"""
|
||||
we_id = xml_tree.find('Id').text
|
||||
dep = self.sudo().search([('we_id', '=', we_id), ('company_id', '=', company.id)])
|
||||
if dep.id is not False:
|
||||
dep.unlink()
|
||||
return 'success'
|
||||
@@ -0,0 +1,234 @@
|
||||
import asyncio
|
||||
|
||||
from odoo import models, fields
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools.translate import _
|
||||
|
||||
from ..common.we_request import we_request_instance
|
||||
|
||||
USER_STATUS = {
|
||||
'active': [1, 4],
|
||||
'not_active': [2, 5]
|
||||
}
|
||||
|
||||
|
||||
def send_list_to_str(send_list):
|
||||
"""
|
||||
convert send list to str
|
||||
:param send_list: wx_id list or string of @all
|
||||
:return:
|
||||
"""
|
||||
if send_list == 'all' or send_list is None:
|
||||
return send_list
|
||||
return '|'.join(send_list)
|
||||
|
||||
|
||||
class Employee(models.Model):
|
||||
_inherit = 'hr.employee'
|
||||
|
||||
we_id = fields.Char(string='Wechat Enterprise User ID')
|
||||
we_department_ids = fields.Many2many('hr.department', 'we_employee_department_rel', 'employee_id', 'department_id',
|
||||
string='Wechat Enterprise Departments')
|
||||
we_extattr = fields.Json(string='Wechat Enterprise User Extattr')
|
||||
|
||||
def write_with_user(self, val):
|
||||
if self.user_id.id is False:
|
||||
user = self.env['res.users'].create({
|
||||
'name': val['name'],
|
||||
'login': val['we_id'],
|
||||
'company_id': val['company_id'],
|
||||
'company_ids': [(4, val['company_id'])],
|
||||
'groups_id': [(6, 0, [self.env.ref('base.group_user').id])],
|
||||
'active': val['active']
|
||||
})
|
||||
val['user_id'] = user.id
|
||||
else:
|
||||
self.user_id.write({
|
||||
'name': val['name'],
|
||||
'active': val['active']
|
||||
})
|
||||
self.write(val)
|
||||
|
||||
def create_with_user(self, val_list):
|
||||
for val in val_list:
|
||||
user = self.env['res.users'].search([('login', '=', val['we_id']), ('active', 'in', [True, False])])
|
||||
if user.id is False:
|
||||
user = self.env['res.users'].create({
|
||||
'name': val['name'],
|
||||
'login': val['we_id'],
|
||||
'company_id': val['company_id'],
|
||||
'company_ids': [(4, val['company_id'])],
|
||||
'groups_id': [(6, 0, [self.env.ref('base.group_user').id])],
|
||||
'active': val['active']
|
||||
})
|
||||
val['user_id'] = user.id
|
||||
|
||||
return self.create(val_list)
|
||||
|
||||
async def sync_user(self, we_department, server_dep_id):
|
||||
we_request = self.env.context.get('we_request')
|
||||
we_app = self.env.context.get('we_app')
|
||||
sync_with_user = we_app.sync_with_user
|
||||
|
||||
user_list = await we_request.department_users(server_dep_id)
|
||||
create_users = []
|
||||
manager_id = None
|
||||
|
||||
for user in user_list:
|
||||
# job
|
||||
job = self.env['hr.job'].search(
|
||||
[('name', '=', user['position']), ('company_id', '=', we_app.company_id.id)])
|
||||
if job.id is False:
|
||||
job = self.env['hr.job'].create({
|
||||
'name': user['position'],
|
||||
'company_id': we_app.company_id.id
|
||||
})
|
||||
|
||||
employee = self.search([('we_id', '=', user['userid']), ('active', 'in', [True, False])])
|
||||
main_department = we_department.search([('we_id', '=', user['main_department'])])
|
||||
|
||||
modify_data = {
|
||||
'name': user['name'],
|
||||
'we_id': user['userid'],
|
||||
'company_id': we_app.company_id.id,
|
||||
'department_id': main_department.id,
|
||||
'we_department_ids': [(4, we_department.id)],
|
||||
'job_id': job.id,
|
||||
'we_extattr': user['extattr'],
|
||||
'active': user['status'] in USER_STATUS['active']
|
||||
}
|
||||
|
||||
if employee.id is False:
|
||||
modify_data['marital'] = False
|
||||
modify_data['work_phone'] = False
|
||||
modify_data['parent_id'] = False
|
||||
create_users.append(modify_data)
|
||||
else:
|
||||
employee.write_with_user(modify_data) if sync_with_user else employee.write(modify_data)
|
||||
|
||||
# set department manager
|
||||
if user['isleader'] == 1 and not manager_id:
|
||||
manager_id = user['userid']
|
||||
|
||||
if len(create_users) > 0:
|
||||
# create users limit 500
|
||||
limit = 500
|
||||
for i in range(0, len(create_users), limit):
|
||||
create_vals = create_users[i:i + limit]
|
||||
# if config set not sync user, not create user
|
||||
self.create_with_user(create_vals) if sync_with_user else self.create(create_vals)
|
||||
if manager_id:
|
||||
we_department.write({'manager_id': self.search([('we_id', '=', manager_id)]).id})
|
||||
|
||||
def send_we_message(self, app_id, to_users, to_departments=None, msgtype='text', **kwargs):
|
||||
"""
|
||||
send message in Wechat Enterprise
|
||||
because sync's info has no tag, so we can't send message to tag, only can send to the single app,
|
||||
the existing parameters are 'to_users', 'to_departments', 'msgtype', 'agentid'
|
||||
:param app_id: wechat enterprise app id used to send message
|
||||
:param to_users: wechat enterprise user we_id list, if to all user, set to '@all'
|
||||
:param to_departments: wechat enterprise department we_id list, if to all department, set to '@all'
|
||||
:param msgtype: message type, reference https://developer.work.weixin.qq.com/document/path/90236
|
||||
:param kwargs: other parameters, reference https://developer.work.weixin.qq.com/document/path/90236
|
||||
:return: message id
|
||||
"""
|
||||
assert app_id, 'app_id is required'
|
||||
if len(to_users) == 0 and len(to_departments) == 0:
|
||||
raise UserError(_('Please select the user or department to send the message!'))
|
||||
|
||||
app = self.env['wechat.enterprise.app'].sudo().browse(int(app_id))
|
||||
we_request = we_request_instance(app.corp_id, app.secret)
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
send_message_task = loop.create_task(we_request.send_message(dict(
|
||||
touser=send_list_to_str(to_users),
|
||||
toparty=send_list_to_str(to_departments),
|
||||
msgtype=msgtype,
|
||||
agentid=app.agentid,
|
||||
**kwargs
|
||||
)))
|
||||
loop.run_until_complete(send_message_task)
|
||||
loop.close()
|
||||
return send_message_task.result()
|
||||
|
||||
def recall_we_message(self, app_id, msg_id):
|
||||
"""
|
||||
recall message in Wechat Enterprise
|
||||
:param app_id: wechat enterprise app id used to recall message
|
||||
:param msg_id: Has been sent message id
|
||||
:return: True or False
|
||||
"""
|
||||
assert app_id, 'app_id is required'
|
||||
assert msg_id, 'msg_id is required'
|
||||
app = self.env['wechat.enterprise.app'].sudo().browse(int(app_id))
|
||||
we_request = we_request_instance(app.corp_id, app.secret)
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
recall_message_task = loop.create_task(we_request.recall_message(msg_id))
|
||||
loop.run_until_complete(recall_message_task)
|
||||
loop.close()
|
||||
return recall_message_task.result() == 'ok'
|
||||
|
||||
def on_we_create_user(self, xml_tree, company):
|
||||
"""
|
||||
on wechat enterprise create user event, only has UserID and Department, no Other fields
|
||||
:param xml_tree: ET.fromstring(str) instance
|
||||
:param company: receive company
|
||||
:return:
|
||||
"""
|
||||
we_id = xml_tree.find('UserID').text
|
||||
# multi dep_id is split by ','
|
||||
we_dep_id = xml_tree.find('Department').text
|
||||
dep_id_list = we_dep_id.split(',')
|
||||
we_departments = self.env['hr.department'].sudo().search(
|
||||
[('we_id', 'in', dep_id_list), ('company_id', '=', company.id)])
|
||||
|
||||
self.sudo().create({
|
||||
'name': f'we_id_{we_id}',
|
||||
'we_id': we_id,
|
||||
'company_id': company.id,
|
||||
# use first department as main department temporarily
|
||||
'department_id': we_departments[0].id if len(we_departments) > 0 else False,
|
||||
'we_department_ids': [(6, 0, we_departments.ids)],
|
||||
'active': True
|
||||
})
|
||||
return 'success'
|
||||
|
||||
def on_we_update_user(self, xml_tree, company):
|
||||
"""
|
||||
on wechat enterprise update user event, only has UserID and Department and NewUserId, no Other fields,
|
||||
only trigger when user change department or user_id
|
||||
:param xml_tree: ET.fromstring(str) instance
|
||||
:param company: receive company
|
||||
:return:
|
||||
"""
|
||||
we_id = xml_tree.find('UserID').text
|
||||
new_we_id = xml_tree.find('NewUserId').text if xml_tree.find('NewUserId') else None
|
||||
# multi dep_id is split by ','
|
||||
we_dep_id = xml_tree.find('Department').text
|
||||
dep_id_list = we_dep_id.split(',')
|
||||
we_departments = self.env['hr.department'].sudo().search(
|
||||
[('we_id', 'in', dep_id_list), ('company_id', '=', company.id)])
|
||||
|
||||
employee = self.sudo().search([('we_id', '=', we_id), ('company_id', '=', company.id)])
|
||||
if employee.id is not False:
|
||||
employee.write({
|
||||
'we_id': new_we_id or we_id,
|
||||
# use first department as main department temporarily
|
||||
'department_id': we_departments[0].id if len(we_departments) > 0 else False,
|
||||
'we_department_ids': [(6, 0, we_departments.ids)]
|
||||
})
|
||||
return 'success'
|
||||
|
||||
def on_we_delete_user(self, xml_tree, company):
|
||||
"""
|
||||
on wechat enterprise delete user event, only has UserID, no Other fields
|
||||
:param xml_tree: ET.fromstring(str) instance
|
||||
:param company: receive company
|
||||
:return:
|
||||
"""
|
||||
we_id = xml_tree.find('UserID').text
|
||||
employee = self.sudo().search([('we_id', '=', we_id), ('company_id', '=', company.id)])
|
||||
if employee.id is not False:
|
||||
employee.write({'active': False})
|
||||
return 'success'
|
||||
@@ -0,0 +1,11 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class WechatEnterpriseLog(models.Model):
|
||||
_name = 'wechat.enterprise.log'
|
||||
_description = 'Wechat Enterprise Log'
|
||||
_order = 'create_date desc'
|
||||
|
||||
company_id = fields.Many2one('res.company', string='Company')
|
||||
we_app_id = fields.Many2one('wechat.enterprise.app', string='Wechat Enterprise App')
|
||||
detail = fields.Text(string='Detail')
|
||||
@@ -0,0 +1,34 @@
|
||||
from odoo import models
|
||||
from odoo.exceptions import AccessDenied
|
||||
|
||||
from ..common.custom_encrypt import CustomEncrypt
|
||||
|
||||
|
||||
class ResUsers(models.Model):
|
||||
_inherit = 'res.users'
|
||||
we_auth_secret = 'RZDeROZp'
|
||||
|
||||
def _check_credentials(self, password, env):
|
||||
"""
|
||||
if password is dict and the key of Non password authentication(npa) is True, then authentication by custom
|
||||
:param password:
|
||||
:param env:
|
||||
:return:
|
||||
"""
|
||||
if isinstance(password, dict) and password.get('npa', False) is True:
|
||||
if not self.authentication_by_non_password(password, env):
|
||||
raise AccessDenied()
|
||||
else:
|
||||
super()._check_credentials(password, env)
|
||||
|
||||
def authentication_by_non_password(self, secret_dict, env):
|
||||
"""
|
||||
authentication by custom, can override this method to implement others authentication
|
||||
:param secret_dict: {'npa': True, 'type': 'we', 'password': 'xxxx'}, can add more type to implement others
|
||||
:param env:
|
||||
:return:
|
||||
"""
|
||||
real_password = secret_dict.get('password', None)
|
||||
if secret_dict.get('type', None) == 'we':
|
||||
return CustomEncrypt.is_equal(ResUsers.we_auth_secret, real_password)
|
||||
return False
|
||||
@@ -0,0 +1,4 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_wechat_enterprise_app,wechat.enterprise.app,model_wechat_enterprise_app,base.group_system,1,1,1,1
|
||||
|
||||
access_wechat_enterprise_log,wechat.enterprise.log,model_wechat_enterprise_log,base.group_system,1,1,1,1
|
||||
|
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 177 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
@@ -0,0 +1,46 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import {registry} from "@web/core/registry";
|
||||
|
||||
const {Component, xml, useEffect, onWillStart} = owl;
|
||||
import {loadJS} from "@web/core/assets";
|
||||
import {useService} from "@web/core/utils/hooks";
|
||||
|
||||
class WEQrcode extends Component {
|
||||
setup() {
|
||||
this.rpc = useService("rpc");
|
||||
|
||||
onWillStart(() => loadJS("http://wwcdn.weixin.qq.com/node/wework/wwopen/js/wwLogin-1.2.7.js"));
|
||||
|
||||
useEffect(async () => {
|
||||
// to safety, we can not request by search_read, so we use rpc
|
||||
const app = await this.rpc('/we/oauth2/info', {
|
||||
app_id: this.props.appId
|
||||
})
|
||||
|
||||
new WwLogin({
|
||||
"id": "wx_reg",
|
||||
"appid": app.corp_id,
|
||||
"agentid": app.agentid,
|
||||
"redirect_uri": encodeURIComponent(app.redirect_uri),
|
||||
});
|
||||
}, () => [])
|
||||
}
|
||||
}
|
||||
|
||||
WEQrcode.template = xml`
|
||||
<div style="width: 100%;height: 100%">
|
||||
<div id="wx_reg" style="width: 100%;height: 100%;display: flex;justify-content: center;align-items: center;"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
WEQrcode.defaultProps = {
|
||||
appId: "",
|
||||
};
|
||||
WEQrcode.extractProps = ({attrs}) => {
|
||||
return {
|
||||
appId: attrs.app_id,
|
||||
};
|
||||
};
|
||||
|
||||
registry.category("view_widgets").add("we_qrcode", WEQrcode);
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="wechat_enterprise_app_form" model="ir.ui.view">
|
||||
<field name="name">wechat.enterprise.app.form</field>
|
||||
<field name="model">wechat.enterprise.app</field>
|
||||
<field name="arch" type="xml">
|
||||
<form delete="0">
|
||||
<group col="4">
|
||||
<group colspan="2">
|
||||
<field name="name"/>
|
||||
<field name="corp_id"/>
|
||||
<field name="sync_with_user"/>
|
||||
</group>
|
||||
|
||||
<group colspan="2">
|
||||
<field name="agentid"/>
|
||||
<field name="secret" password="True"/>
|
||||
</group>
|
||||
|
||||
<group colspan="4">
|
||||
<field name="description"/>
|
||||
</group>
|
||||
|
||||
<h2 style="margin-top: 4px;">Url Verify Settings</h2>
|
||||
|
||||
<group colspan="4">
|
||||
<field name="verify_txt" filename="verify_txt_filename"/>
|
||||
<filed name="verify_txt_filename" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="we_company_extend" model="ir.ui.view">
|
||||
<field name="name">we.company.extend</field>
|
||||
<field name="model">res.company</field>
|
||||
<field name="inherit_id" ref="base.view_company_form"/>
|
||||
<field name="mode">primary</field>
|
||||
<field name="priority">2</field>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//page[@name='general_info']" position="after">
|
||||
<page string="Wechat Enterprise Apps" name="wechat_enterprise_apps">
|
||||
<group>
|
||||
<field name="we_corp_id"/>
|
||||
</group>
|
||||
|
||||
<h2 style="margin-top: 4px;">Callback Settings</h2>
|
||||
|
||||
<group>
|
||||
<field name="we_cb_token" password="True"/>
|
||||
<field name="we_cb_encoding_AES_key" password="True"/>
|
||||
</group>
|
||||
|
||||
<field name="app_ids">
|
||||
<tree create="1" delete="1">
|
||||
<field name="name"/>
|
||||
<field name="agentid"/>
|
||||
<field name="sync_with_user"/>
|
||||
<!-- need this field, if not can not save filename -->
|
||||
<field name="verify_txt_filename" invisible="1"/>
|
||||
<field name="secret"/>
|
||||
<button name="run_sync" string="Sync Organization" type="object"
|
||||
icon="fa-refresh text-primary"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="wechat_enterprise_log_tree" model="ir.ui.view">
|
||||
<field name="name">wechat.enterprise.log.tree</field>
|
||||
<field name="model">wechat.enterprise.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree edit="0" create="0" delete="0">
|
||||
<field name="company_id"/>
|
||||
<field name="we_app_id"/>
|
||||
<field name="create_uid"/>
|
||||
<field name="create_date"/>
|
||||
<field name="detail"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="wechat_enterprise_log_form" model="ir.ui.view">
|
||||
<field name="name">wechat.enterprise.log.form</field>
|
||||
<field name="model">wechat.enterprise.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<form edit="0" create="0" delete="0">
|
||||
<group col="4">
|
||||
<group colspan="2">
|
||||
<field name="company_id" options="{'no_open': True, 'no_quick_create': True}"/>
|
||||
<field name="create_uid"/>
|
||||
</group>
|
||||
<group colspan="2">
|
||||
<field name="we_app_id" options="{'no_open': True, 'no_quick_create': True}"/>
|
||||
<field name="create_date"/>
|
||||
</group>
|
||||
<group colspan="4">
|
||||
<field name="detail"/>
|
||||
</group>
|
||||
</group>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="wechat_enterprise_log_act_window" model="ir.actions.act_window">
|
||||
<field name="name">Wechat Enterprise Logs</field>
|
||||
<field name="res_model">wechat.enterprise.log</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
</record>
|
||||
|
||||
<menuitem
|
||||
id="menu_wechat_enterprise_log"
|
||||
name="Wechat Enterprise Logs"
|
||||
parent="hr.menu_hr_root"
|
||||
groups="base.group_system"
|
||||
action="wechat_enterprise_log_act_window"
|
||||
sequence="99"/>
|
||||
</odoo>
|
||||
@@ -12,8 +12,8 @@ class IssueLibrary(models.Model):
|
||||
name = fields.Char(string='议题名称')
|
||||
code = fields.Char(string='编号', required=True, copy=False,
|
||||
default=lambda self: self.env['ir.sequence'].next_by_code('issue_library_code'))
|
||||
type_issue = fields.Selection([('business_reporting', '业务汇报'), ('project_progress', '项目进展'),
|
||||
('hr', '人力资源'), ('financial_reporting', '财务报告')], string="议题类别")
|
||||
type_issue = fields.Selection([('business_report', '业务汇报'), ('project_progress', '项目进展'),
|
||||
('hr', '人力资源'), ('account_report', '财务报告')], string="议题类别")
|
||||
objective = fields.Char(string='目的')
|
||||
employee_ids = fields.Many2many('hr.employee', string='参与人员')
|
||||
meeting_et = fields.Datetime(string='会议预计时间')
|
||||
|
||||
Reference in New Issue
Block a user