diff --git a/jikimo_frontend/static/src/js/custom_form_status_indicator.js b/jikimo_frontend/static/src/js/custom_form_status_indicator.js index 306142d4..912c8efa 100644 --- a/jikimo_frontend/static/src/js/custom_form_status_indicator.js +++ b/jikimo_frontend/static/src/js/custom_form_status_indicator.js @@ -5,7 +5,7 @@ import {patch} from '@web/core/utils/patch'; import {_t} from "@web/core/l10n/translation"; import {FormStatusIndicator} from "@web/views/form/form_status_indicator/form_status_indicator"; import {ListRenderer} from "@web/views/list/list_renderer"; -import {StatusBarField} from "@web/views/fields/statusbar/statusbar_field"; +// import {StatusBarField} from "@web/views/fields/statusbar/statusbar_field"; import {Field} from "@web/views/fields/field"; @@ -153,34 +153,34 @@ patch(ListRenderer.prototype, 'jikimo_frontend.ListRenderer', { // 根据进度条设置水印 -const statusbar_params = { - '已完工': 'bg-primary', - '完成': 'bg-primary', - '采购订单': 'bg-primary', - '作废': 'bg-danger', - '封存(报废)': 'bg-danger', -} -patch(StatusBarField.prototype, 'jikimo_frontend.StatusBarField', { - setup() { - owl.onMounted(this.ribbons); - return this._super(...arguments); - }, - ribbons() { - try { - const dom = $('.o_form_sheet.position-relative') - const status = statusbar_params[this.currentName] - if(status && dom.length) { - dom.prepend(`
-
- ${this.currentName} -
-
`) - } - } catch (e) { - console.log(e) - } - } -}) +// const statusbar_params = { +// '已完工': 'bg-primary', +// '完成': 'bg-primary', +// '采购订单': 'bg-primary', +// '作废': 'bg-danger', +// '封存(报废)': 'bg-danger', +// } +// patch(StatusBarField.prototype, 'jikimo_frontend.StatusBarField', { +// setup() { +// owl.onMounted(this.ribbons); +// return this._super(...arguments); +// }, +// ribbons() { +// try { +// const dom = $('.o_form_sheet.position-relative') +// const status = statusbar_params[this.currentName] +// if(status && dom.length) { +// dom.prepend(`
+//
+// ${this.currentName} +//
+//
`) +// } +// } catch (e) { +// console.log(e) +// } +// } +// }) $(function () { document.addEventListener('click', function () { diff --git a/jikimo_frontend/static/src/scss/custom_style.scss b/jikimo_frontend/static/src/scss/custom_style.scss index 0b6fb8bb..cbd1bb4d 100644 --- a/jikimo_frontend/static/src/scss/custom_style.scss +++ b/jikimo_frontend/static/src/scss/custom_style.scss @@ -530,4 +530,11 @@ div:has(.o_required_modifier) > label::before { // 修复表格内容覆盖表头bug .o_list_renderer .o_list_table tbody th { position: unset; -} \ No newline at end of file +} + +// 修改表格下拉框会被表格下面数据框覆盖的bug +.tab-pane .o_field_widget { + position: relative; + z-index: 1; +} + diff --git a/jikimo_system_order/__init__.py b/jikimo_system_order/__init__.py new file mode 100644 index 00000000..bbc55806 --- /dev/null +++ b/jikimo_system_order/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- + +from . import controllers +from . import models +from . import wizard diff --git a/jikimo_system_order/__manifest__.py b/jikimo_system_order/__manifest__.py new file mode 100644 index 00000000..7f85ed57 --- /dev/null +++ b/jikimo_system_order/__manifest__.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +{ + 'name': "jikimo_system_order", + + 'summary': """ + 系统工单""", + + 'description': """ + 用于处理针对系统的工作任务; + 员工可以通过系统工单发起申请,由维护人员处理以后,填写处理结果。 + """, + + 'author': "机企猫", + 'website': "http://www.jikimo.com", + + # Categories can be used to filter modules in modules listing + # Check https://github.com/odoo/odoo/blob/master/odoo/addons/base/module/module_data.xml + # for the full list + 'category': 'Uncategorized', + 'version': '0.1', + + # any module necessary for this one to work correctly + 'depends': ['base','mail'], + + # always loaded + 'data': [ + 'security/account_security.xml', + 'security/ir.model.access.csv', + 'wizard/order_wizard.xml', + 'views/notice_user_config.xml', + 'views/yizuo_system_order_view.xml', + 'views/work_order_number.xml', + 'views/res_config_settings_views.xml', + ], + # only loaded in demonstration mode + 'demo': [ + 'demo/demo.xml', + ], +} \ No newline at end of file diff --git a/jikimo_system_order/controllers/__init__.py b/jikimo_system_order/controllers/__init__.py new file mode 100644 index 00000000..457bae27 --- /dev/null +++ b/jikimo_system_order/controllers/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import controllers \ No newline at end of file diff --git a/jikimo_system_order/controllers/controllers.py b/jikimo_system_order/controllers/controllers.py new file mode 100644 index 00000000..cfcad7d0 --- /dev/null +++ b/jikimo_system_order/controllers/controllers.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from odoo import http + +# class TopSystemOrder(http.Controller): +# @http.route('/jikimo_system_order/jikimo_system_order/', auth='public') +# def index(self, **kw): +# return "Hello, world" + +# @http.route('/jikimo_system_order/jikimo_system_order/objects/', auth='public') +# def list(self, **kw): +# return http.request.render('jikimo_system_order.listing', { +# 'root': '/jikimo_system_order/jikimo_system_order', +# 'objects': http.request.env['jikimo_system_order.jikimo_system_order'].search([]), +# }) + +# @http.route('/jikimo_system_order/jikimo_system_order/objects//', auth='public') +# def object(self, obj, **kw): +# return http.request.render('jikimo_system_order.object', { +# 'object': obj +# }) \ No newline at end of file diff --git a/jikimo_system_order/demo/demo.xml b/jikimo_system_order/demo/demo.xml new file mode 100644 index 00000000..b167f9be --- /dev/null +++ b/jikimo_system_order/demo/demo.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jikimo_system_order/models/__init__.py b/jikimo_system_order/models/__init__.py new file mode 100644 index 00000000..87b51a43 --- /dev/null +++ b/jikimo_system_order/models/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- + +from . import constant +from . import order_classify +from . import system_work_order +from . import work_order_template +from . import res_config_setting diff --git a/jikimo_system_order/models/constant.py b/jikimo_system_order/models/constant.py new file mode 100644 index 00000000..414371b9 --- /dev/null +++ b/jikimo_system_order/models/constant.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- + +# 工单状态 +STATE_SELECTION = [('draft', u'草稿'), ('unconfirmed', u'待确认'), ('pending', u'待处理'), + ('processed', u'已处理待评分'), ('completed', u'已完成'), ('closed', u'已关闭')] + +GRADE = [('1', '1非常不满意'), ('2', '2不满意'), ('3', '3一般'), ('4', '4满意'), ('5', '5非常满意')] diff --git a/jikimo_system_order/models/order_classify.py b/jikimo_system_order/models/order_classify.py new file mode 100644 index 00000000..d143487d --- /dev/null +++ b/jikimo_system_order/models/order_classify.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api +from odoo.exceptions import ValidationError + + +class OrderClassify(models.Model): + _name = 'order.classify' + _order = 'sequence, name' + + + @api.constrains('name') + def check_base_name(self): + """类型名称唯一""" + name_obj = self.env['order.classify'].search([('name', '=', self.name)]) + if len(name_obj) >= 2: + raise ValidationError(u'该类型已存在') + + # 名称 + name = fields.Char(string=u'名称', size=20) + # 排序 + sequence = fields.Integer(default=10) + # 是否有效 + state = fields.Boolean(default=True, string='是否有效') + diff --git a/jikimo_system_order/models/res_config_setting.py b/jikimo_system_order/models/res_config_setting.py new file mode 100644 index 00000000..44a9a1d7 --- /dev/null +++ b/jikimo_system_order/models/res_config_setting.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- + +import logging +from odoo import api, fields, models, _ + +_logger = logging.getLogger(__name__) + + +class ResModelWeConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + lost_agent_id = fields.Char('企微通知应用ID') + + @api.model + def get_values(self): + """ + 重载获取参数的方法,参数都存在系统参数中 + :return: + """ + values = super(ResModelWeConfigSettings, self).get_values() + config = self.env['ir.config_parameter'].sudo() + lost_agent_id = config.get_param('lost_agent_id', default='') + values.update( + lost_agent_id=lost_agent_id, + ) + return values + + def set_values(self): + super(ResModelWeConfigSettings, self).set_values() + ir_config = self.env['ir.config_parameter'].sudo() + ir_config.set_param("lost_agent_id", self.lost_agent_id or "") + diff --git a/jikimo_system_order/models/system_work_order.py b/jikimo_system_order/models/system_work_order.py new file mode 100644 index 00000000..1a7bff5e --- /dev/null +++ b/jikimo_system_order/models/system_work_order.py @@ -0,0 +1,183 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api +from odoo.exceptions import ValidationError +from odoo import exceptions +from .constant import STATE_SELECTION, GRADE +import datetime +import logging + + +class SystemWorkOrder(models.Model): + _name = 'system.work.order' + _inherit = ['mail.thread', 'mail.activity.mixin'] + _order = 'date desc' + _description = u'系统工单' + _rec_name = 'order_number' + + def get_is_technicist(self): + self._cr.execute( + "select u.id from res_users u left join res_groups_users_rel r on r.uid = u.id where r.gid in (select g.id from res_groups g where g.name = '技术员权限') and u.id ='%s'", + (self.env.user.id,)) + hr = self._cr.dictfetchall() + if len(hr) > 0: + return True + else: + return False + + # def get_user_department_id(self): + # """根据用户id系统员工id""" + # employee = self.env['hr.employee'].sudo().search([('user_id', '=', self.env.uid)], limit=1) + # if employee: + # if len(employee) > 0: + # if not employee.department_id: + # raise exceptions.Warning(u'您当前使用的用户没有所属部门') + # return employee.department_id + # else: + # return False + # else: + # raise exceptions.Warning(u'您当前使用的用户没有关联员工') + + @api.onchange('order_template_id') + def get_title(self): + """选择模板自动填充""" + if self.order_template_id: + self.title = self.order_template_id.title_template + self.text = self.order_template_id.text_template + + # 工单编号 + order_number = fields.Char(string=u'工单编号', default='/') + # 紧急程度 + urgency_degree = fields.Selection([('0', u'0星'), ('1', u'一星'), ('2', u'二星'), ('3', u'三星'), ('4', u'四星'), + ('5', u'五星')], string=u'紧急程度', help='五星为最紧急!', default='5') + # 工单分类(可以配置,并调整优先级) + order_type = fields.Many2one('order.classify', string=u'工单分类', domain=[('state', '=', True)]) + # 发起人所属公司(res.company) + initiator_company_id = fields.Many2one('res.company', string=u'发起人所属公司', default=lambda self: self.env.user.company_id) + # 发起人部门(hr.department) + # initiator_department_id = fields.Many2one('hr.department', string=u'发起人部门', default=get_user_department_id) + # 发起人(hr.employee) + initiator_id = fields.Many2one('res.users', string=u'发起人', default=lambda self: self.env.user) + # 发起时间 + date = fields.Datetime(string=u'发起时间', default=lambda self: fields.datetime.now()) + # 确认人 + confirm_id = fields.Many2one('res.users', string=u'确认人') + # 确认日期 + confirmation_date = fields.Datetime(string=u'确认时间') + # 模板 + order_template_id = fields.Many2one('work.order.template', string=u'模板', domain=[('state', '=', True)]) + # 标题 + title = fields.Char(string=u'标题') + # 正文 + text = fields.Html(string=u'正文') + # 状态[草稿\待确认\待处理\已处理\已关闭] + state = fields.Selection(STATE_SELECTION, default='draft', string=u'状态') + # 关闭原因 + close_cause = fields.Text(string=u'关闭问题原因') + # 关闭时间 + close_time = fields.Datetime(string=u'关闭问题时间') + # 关闭人 + close_user_id = fields.Many2one('res.users', string=u'关闭人') + # 解决人 + solve_people_id = fields.Many2one('res.users', string=u'解决人') + # 用户实际问题 + users_problem = fields.Text(string=u'用户实际问题') + # 最终解决方案 + solution = fields.Text(string=u'最终解决方案') + # 判断是否为技术人员 + # is_technicist = fields.Boolean(string=u'是否为技术人员', default=get_is_technicist) + # 打分 + grade = fields.Selection(GRADE, string=u'评分') + # 评价按钮的显示 + is_display = fields.Boolean('控制显示评价按钮', compute='compute_is_display') + + def compute_is_display(self): + for item in self: + if item.state == 'processed' and self.env.user.id == item.initiator_id.id: + item.is_display = True + else: + item.is_display = False + + @api.onchange('order_type') + def _onchange_order_type(self): + self.order_template_id = None + self.title = None + self.text = None + + @api.model + def create(self, vals): + # 创建编号 + if vals.get('order_number', '/') == '/': + vals['order_number'] = self.env['ir.sequence'].get('system.work.order') or '/' + return super(SystemWorkOrder, self).create(vals) + + def do_draft(self, order=None): + """状态草稿""" + bill = self + if order: + bill = order + if bill.state == 'unconfirmed': + state_remark = u'待确认 --> 草稿' + # bill.message_post(u'操作人:%s,操作时间:%s,状态变更过程:%s' % (self.env.user.name, + # (datetime.datetime.now() + datetime.timedelta(hours=8)).strftime('%Y-%m-%d %H:%M:%S'), state_remark)) + bill.state = 'draft' + + def do_unconfirmed(self): + """状态待确认""" + if self.state == 'draft': + state_remark = u'草稿 --> 待确认' + # self.message_post(u'操作人:%s,操作时间:%s,状态变更过程:%s' % ( + # self.env.user.name, + # (datetime.datetime.now() + datetime.timedelta(hours=8)).strftime('%Y-%m-%d %H:%M:%S'), state_remark)) + self.state = 'unconfirmed' + # 获取通知人 + objs = self.env['system.order.notice'].search([]) + user_ids = objs.notice_user_ids.filtered(lambda item: item.we_employee_id not in ['', False]) + we_employee_ids = user_ids.mapped('we_employee_id') + lost_agent_id = self.env['ir.config_parameter'].sudo().get_param('lost_agent_id') + wechat = self.env['we.config'].sudo().get_wechat(agent_id=lost_agent_id) + # agent_id, user_ids, content + content = """您有一张工单待处理:**工单标题:{2}** + >创建人:{1} + >提交时间:{3} + >紧急程度:{0}星 + 请查看工单消息,并及时处理! + """.format(self.urgency_degree, + self.initiator_id.name, self.title, (self.date + datetime.timedelta(hours=8)).strftime('%Y-%m-%d %H:%M')) + for we_employee_id in we_employee_ids: + try: + wechat.message.send_markdown(agent_id=lost_agent_id, user_ids=we_employee_id, content=content) + except Exception as e: + logging.error('工单处理发送消息异常%s' % str(e)) + + return True + + def do_pending(self): + """状态待处理""" + if self.state == 'unconfirmed': + state_remark = u'待确认 --> 待处理' + # self.message_post(u'操作人:%s,操作时间:%s,状态变更过程:%s' % ( + # self.env.user.name, + # (datetime.datetime.now() + datetime.timedelta(hours=8)).strftime('%Y-%m-%d %H:%M:%S'), state_remark)) + self.state = 'pending' + self.confirm_id = self.env.user + self.confirmation_date = fields.datetime.now() + return True + + def urned_off(self): + """状态关闭""" + if self.close_cause: + self.state = 'closed' + self.close_time = fields.datetime.now() + else: + raise ValidationError(u'请注明关闭原因') + return True + + def unlink(self): + for item in self: + if item.state != "draft": + raise ValidationError(u'只能删除状态为【草稿】的工单。') + elif item.env.uid != item.initiator_id.id: + raise ValidationError(u'非本人不能删除') + else: + super(SystemWorkOrder, item).unlink() diff --git a/jikimo_system_order/models/work_order_template.py b/jikimo_system_order/models/work_order_template.py new file mode 100644 index 00000000..f50fd42d --- /dev/null +++ b/jikimo_system_order/models/work_order_template.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api + + +class WorkOrderTemplate(models.Model): + _name = 'work.order.template' + _order = 'num' + + # 编号 + num = fields.Char(string=u'编号', default='/') + # 名称 + name = fields.Char(string=u'模板名称', required="1") + # 分类 + work_order_type = fields.Many2one('order.classify', string=u'系统工单分类', domain=[('state', '=', True)]) + # 模板标题 + title_template = fields.Char(string=u'模板标题') + # 模板正文 + text_template = fields.Html(string=u'模板正文') + # 模板说明 + template_explain = fields.Text(string=u'模板说明') + # 是否有效 + state = fields.Boolean(default=True, string=u'是否有效') + + @api.model + def create(self, vals): + # 创建编号 + if vals.get('num', '/') == '/': + vals['num'] = self.env['ir.sequence'].get('work.order.template') or '/' + return super(WorkOrderTemplate, self).create(vals) + + +class SystemOrderNotice(models.Model): + _name = 'system.order.notice' + _description = '工单处理人设置' + + notice_user_ids = fields.Many2many('res.users', string='工单处理人') + diff --git a/jikimo_system_order/security/account_security.xml b/jikimo_system_order/security/account_security.xml new file mode 100644 index 00000000..8356964e --- /dev/null +++ b/jikimo_system_order/security/account_security.xml @@ -0,0 +1,24 @@ + + + + + + 运维权限 + + + + 用户访问工单信息 + + + [('initiator_id', '=', user.id)] + + + + 运维访问工单信息 + + + [(1, '=', 1)] + + + + diff --git a/jikimo_system_order/security/ir.model.access.csv b/jikimo_system_order/security/ir.model.access.csv new file mode 100644 index 00000000..5c76352d --- /dev/null +++ b/jikimo_system_order/security/ir.model.access.csv @@ -0,0 +1,16 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink + +inside_system_order_classify_r,jikimo_system_order.order_classify,model_order_classify,,1,1,1,1 +inside_system_work_order_rc,jikimo_system_order.system_work_order,model_system_work_order,,1,1,1,1 +inside_work_order_template_r,jikimo_system_order.work_order_template,model_work_order_template,,1,1,1,1 + +inside_system_order_classify_rwc,jikimo_system_order.order_classify,model_order_classify,group_operations_permissions_rwc,1,1,1,0 +inside_system_work_order_rwc,jikimo_system_order.system_work_order,model_system_work_order,group_operations_permissions_rwc,1,1,1,0 +inside_work_order_template_rwc,jikimo_system_order.work_order_template,model_work_order_template,group_operations_permissions_rwc,1,1,1,0 + +order_close_wizard_group_user,jikimo_system_order.order_close_wizard,model_order_close_wizard,base.group_user,1,1,1,1 +order_other_wizard_group_user,jikimo_system_order.order_other_wizard,model_order_other_wizard,base.group_user,1,1,1,1 +order_technician_wizard_group_user,jikimo_system_order.order_technician_wizard,model_order_technician_wizard,base.group_user,1,1,1,1 +system_work_order_wizard_group_user,jikimo_system_order.system_work_order_wizard,model_system_work_order_wizard,base.group_user,1,1,1,1 + +system_order_notice_group_user,jikimo_system_order.system_order_notice,model_system_order_notice,base.group_user,1,1,1,1 \ No newline at end of file diff --git a/jikimo_system_order/static/description/icon.png b/jikimo_system_order/static/description/icon.png new file mode 100644 index 00000000..24aa8fc8 Binary files /dev/null and b/jikimo_system_order/static/description/icon.png differ diff --git a/jikimo_system_order/views/notice_user_config.xml b/jikimo_system_order/views/notice_user_config.xml new file mode 100644 index 00000000..353afb96 --- /dev/null +++ b/jikimo_system_order/views/notice_user_config.xml @@ -0,0 +1,58 @@ + + + + # ---------- 工单通知处理人设置 ------------ + + + tree.system.order.notice + system.order.notice + + + + + + + + + + search.system.order.notice + system.order.notice + + + + + + + + + + + + + + 工单处理人 + system.order.notice + tree + [] + {} + +

+ [工单处理人] 还没有哦!点左上角的[创建]按钮,沙发归你了! +

+

+

+
+
+ + + + + + + + + + +
+
\ No newline at end of file diff --git a/jikimo_system_order/views/res_config_settings_views.xml b/jikimo_system_order/views/res_config_settings_views.xml new file mode 100644 index 00000000..40cdc443 --- /dev/null +++ b/jikimo_system_order/views/res_config_settings_views.xml @@ -0,0 +1,28 @@ + + + + + res.config.settings.we.view.form.inherit.bpm + res.config.settings + + + +
+

企微通知应用ID

+
+
+
+
+
+
+
+
+
+
+ + + + + diff --git a/jikimo_system_order/views/work_order_number.xml b/jikimo_system_order/views/work_order_number.xml new file mode 100644 index 00000000..63f0d5a3 --- /dev/null +++ b/jikimo_system_order/views/work_order_number.xml @@ -0,0 +1,23 @@ + + + + + + + seq_work_order + + system.work.order + SO%(year)s%(month)s%(day)s + 1 + + + + + seq_order_template + + work.order.template + TL + 1 + + + \ No newline at end of file diff --git a/jikimo_system_order/views/yizuo_system_order_view.xml b/jikimo_system_order/views/yizuo_system_order_view.xml new file mode 100644 index 00000000..006a085d --- /dev/null +++ b/jikimo_system_order/views/yizuo_system_order_view.xml @@ -0,0 +1,243 @@ + + + + + + + 工单信息 + system.work.order + + + + + + + + + + + + + + 新建系统工单 + system.work.order + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + 搜索工单 + system.work.order + + + + + + + + + + + + + + + + + + + + + + + + + + + 工单图表 + system.work.order + + + + + + + + + + + + 工单 + system.work.order + tree,form,search,graph,pivot + + + + + 工单模板信息 + work.order.template + + + + + + + + + + + + + + + 新建系统工单模板 + work.order.template + +
+ + + + + + + + + + + +
+
+
+ + + + 工单模板 + work.order.template + tree,form + + + + + 工单分类信息 + order.classify + + + + + + + + + + + + 新建系统分类信息 + order.classify + +
+ + + + + + + +
+
+
+ + + + 工单分类 + order.classify + tree,form + + + + + + + + +
+
\ No newline at end of file diff --git a/jikimo_system_order/wizard/__init__.py b/jikimo_system_order/wizard/__init__.py new file mode 100644 index 00000000..8cf586ce --- /dev/null +++ b/jikimo_system_order/wizard/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- + +from . import order_other_wizard +from . import order_technician_wizard +from . import order_close_wizard +from . import system_work_order_wizard diff --git a/jikimo_system_order/wizard/order_close_wizard.py b/jikimo_system_order/wizard/order_close_wizard.py new file mode 100644 index 00000000..ca10ae7c --- /dev/null +++ b/jikimo_system_order/wizard/order_close_wizard.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api +from odoo.addons.jikimo_system_order.models.constant import STATE_SELECTION +from odoo.exceptions import ValidationError +import datetime, logging + + +class OrderCloseWizard(models.TransientModel): + _name = 'order.close.wizard' + + + def get_context(self): + if self._context.get('active_id'): + obj = self.env['system.work.order'].browse(self._context.get('active_id')) + if obj.initiator_id.id != self.env.user.id: + raise ValidationError(u'非本人无法操作') + return obj + + order_id = fields.Many2one('system.work.order', string=u'工单ID', + default=lambda self: self.get_context().id) + # 关闭原因 + close_cause = fields.Text(string=u'关闭问题原因', default=lambda self: self.get_context().close_cause) + # 关闭时间 + close_time = fields.Datetime(string=u'关闭问题时间', default=fields.datetime.now()) + # 状态 + state = fields.Selection(STATE_SELECTION, default='closed', string=u'状态') + # 关闭人 + close_user_id = fields.Many2one('res.users', string=u'关闭人', default=lambda self: self.env.user) + + + def sure(self): + self.order_id.close_cause = self.close_cause + self.order_id.close_time = self.close_time + if self.order_id.state == 'unconfirmed': + state_remark = u'待确认 --> 已关闭' + if self.order_id.state == 'pending': + state_remark = u'待处理 --> 已关闭' + if self.order_id.state == 'processed': + state_remark = u'已处理待评分 --> 已关闭' + # self.order_id.message_post(u'操作人:%s,操作时间:%s,状态变更过程:%s' % ( + # self.env.user.name, + # (datetime.datetime.now() + datetime.timedelta(hours=8)).strftime('%Y-%m-%d %H:%M:%S'), state_remark)) + self.order_id.state = self.state + self.order_id.close_user_id = self.close_user_id + we_employee_ids = [] + if self.order_id.initiator_id.we_employee_id: + we_employee_ids.append(self.order_id.initiator_id.we_employee_id) + lost_agent_id = self.env['ir.config_parameter'].sudo().get_param('lost_agent_id') + wechat = self.env['we.config'].sudo().get_wechat(agent_id=lost_agent_id) + # agent_id, user_ids, content + content = """您提交的工单-**工单标题:{0}**-**已关闭** + >提交时间:{1} + >处理时间:{2} + >处理人:{3} + 如有问题,请联系系统管理员! + """.format(self.order_id.title, + (self.order_id.date + datetime.timedelta(hours=8)).strftime( + '%Y-%m-%d %H:%M'), (datetime.datetime.now() + datetime.timedelta( + hours=8)).strftime('%Y-%m-%d %H:%M'), self.env.user.name or '') + # wechat.message.send_markdown(agent_id=lost_agent_id, user_ids=we_employee_ids, content=content) + for we_employee_id in we_employee_ids: + try: + wechat.message.send_markdown(agent_id=lost_agent_id, user_ids=we_employee_id, content=content) + except Exception as e: + logging.error('工单关闭发送消息异常%s' % str(e)) + return {} + + + + + + + + + + + + diff --git a/jikimo_system_order/wizard/order_other_wizard.py b/jikimo_system_order/wizard/order_other_wizard.py new file mode 100644 index 00000000..3f6c8710 --- /dev/null +++ b/jikimo_system_order/wizard/order_other_wizard.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api +from odoo.exceptions import ValidationError +from odoo.addons.jikimo_system_order.models.constant import STATE_SELECTION, GRADE +import datetime + + +class OrderOtherWizard(models.TransientModel): + _name = 'order.other.wizard' + + + def get_context(self): + if self._context.get('active_id'): + obj = self.env['system.work.order'].browse(self._context.get('active_id')) + if obj.initiator_id.id != self.env.user.id: + raise ValidationError(u'非本人无法操作') + return obj + + order_id = fields.Many2one('system.work.order', string=u'工单ID', + default=lambda self: self.get_context().id) + # 关闭时间 + close_time = fields.Datetime(string=u'关闭时间', default=fields.datetime.now()) + # 状态 + state = fields.Selection(STATE_SELECTION, default='completed', string=u'状态') + # 打分 + grade = fields.Selection(GRADE, string=u'评分') + # 关闭人 + close_user_id = fields.Many2one('res.users', string=u'关闭人', default=lambda self: self.env.user) + + + def sure(self): + self.order_id.close_time = self.close_time + self.order_id.grade = self.grade + if self.order_id.state == 'processed': + state_remark = u'已处理待评分 --> 已完成' + # self.order_id.message_post(u'操作人:%s,操作时间:%s,状态变更过程:%s' % ( + # self.env.user.name, + # (datetime.datetime.now() + datetime.timedelta(hours=8)).strftime('%Y-%m-%d %H:%M:%S'), state_remark)) + self.order_id.state = self.state + self.order_id.close_user_id = self.close_user_id + return {} diff --git a/jikimo_system_order/wizard/order_technician_wizard.py b/jikimo_system_order/wizard/order_technician_wizard.py new file mode 100644 index 00000000..78458038 --- /dev/null +++ b/jikimo_system_order/wizard/order_technician_wizard.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api +from odoo.addons.jikimo_system_order.models.constant import STATE_SELECTION +import datetime +import logging + + +class OrderTechnicianWizard(models.TransientModel): + _name = 'order.technician.wizard' + + order_id = fields.Many2one('system.work.order', string=u'工单ID', + default=lambda self: self.env.context.get('active_id')) + # 解决人 + solve_people_id = fields.Many2one('res.users', string=u'解决人', default=lambda self: self.env.user) + # 用户实际问题 + users_problem = fields.Text(string=u'用户实际问题') + # 最终解决方案 + solution = fields.Text(string=u'最终解决方案') + # 状态 + state = fields.Selection(STATE_SELECTION, default='processed', string=u'状态') + + def sure(self): + self.order_id.solve_people_id = self.solve_people_id + self.order_id.users_problem = self.users_problem + self.order_id.solution = self.solution + if self.order_id.state == 'pending': + state_remark = u'待处理 --> 已处理待评分' + # self.order_id.message_post(u'操作人:%s,操作时间:%s,状态变更过程:%s' % ( + # self.env.user.name, + # (datetime.datetime.now() + datetime.timedelta(hours=8)).strftime('%Y-%m-%d %H:%M:%S'), state_remark)) + self.order_id.state = self.state + # 获取通知人 + # objs = self.env['system.order.notice'].search([]) + # user_ids = objs.notice_user_ids.filtered(lambda item: item.we_employee_id not in ['', False]) + # we_employee_ids = user_ids.mapped('we_employee_id') + we_employee_ids = [] + if self.order_id.initiator_id.we_employee_id: + we_employee_ids.append(self.order_id.initiator_id.we_employee_id) + print(we_employee_ids) + lost_agent_id = self.env['ir.config_parameter'].sudo().get_param('lost_agent_id') + wechat = self.env['we.config'].sudo().get_wechat(agent_id=lost_agent_id) + # agent_id, user_ids, content + content = """您提交的工单-**工单标题:{0}**-**已处理** + >提交时间:{1} + >处理反馈:{4} + >处理时间:{2} + >处理人:{3} + 如有问题,请联系系统管理员! + """.format(self.order_id.title, + (self.order_id.date + datetime.timedelta(hours=8)).strftime('%Y-%m-%d %H:%M'), (datetime.datetime.now() + datetime.timedelta(hours=8)).strftime('%Y-%m-%d %H:%M'), self.env.user.name or '', self.solution or '') + # wechat.message.send_markdown(agent_id=lost_agent_id, user_ids=we_employee_ids, content=content) + for we_employee_id in we_employee_ids: + try: + wechat.message.send_markdown(agent_id=lost_agent_id, user_ids=we_employee_id, content=content) + except Exception as e: + logging.error('工单处理发送消息异常%s' % str(e)) + + return {} diff --git a/jikimo_system_order/wizard/order_wizard.xml b/jikimo_system_order/wizard/order_wizard.xml new file mode 100644 index 00000000..64c13ccd --- /dev/null +++ b/jikimo_system_order/wizard/order_wizard.xml @@ -0,0 +1,122 @@ + + + + + + 技术员向导 + order.technician.wizard + +
+ + + + + + +
+
+
+
+
+ + + 技术员编辑 + ir.actions.act_window + order.technician.wizard + form + + {'display_default_code':False} + new + + + + + + + 其它向导 + order.other.wizard + +
+ + + + + + +
+
+
+
+
+ + + 其它编辑 + ir.actions.act_window + order.other.wizard + form + + {'display_default_code':False} + new + + + + + 关闭向导 + order.close.wizard + +
+ + + + + + +
+
+
+
+
+ + + 关闭工单 + ir.actions.act_window + order.close.wizard + form + + {'display_default_code':False} + new + + + + system_work_order_wizard_view + system.work.order.wizard + +
+ +
+
+ +
+
+ + + 二次确认 + ir.actions.act_window + system.work.order.wizard + form + new + +
+
diff --git a/jikimo_system_order/wizard/system_work_order_wizard.py b/jikimo_system_order/wizard/system_work_order_wizard.py new file mode 100644 index 00000000..8c8f95dc --- /dev/null +++ b/jikimo_system_order/wizard/system_work_order_wizard.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# @Time : 2017/12/12 9:46 +# @Author : GuoXiang +# @Site : +# @File : system_work_order_wizard.py +# @Software: PyCharm +# @Desc : +# @license : Copyright©2018 www.dasmaster.com All Rights Reserved. +# @Contact : xg1230205321@163.com +from odoo import models, api, fields +from odoo.exceptions import ValidationError + + +class SystemWorkOrderWizard(models.TransientModel): + _name = "system.work.order.wizard" + _description = u"追回确认" + + + def _get_explain(self): + if self._context.get('object_id'): + obj = self.env['system.work.order'].browse(self._context.get('object_id')) + if obj.initiator_id.id != self.env.user.id: + raise ValidationError(u'非本人无法操作') + if self._context.get('explain'): + return self._context["explain"] + + explain = fields.Char(default=_get_explain) + + + def sure(self): + """ + 确认 + :return: + """ + if self._context.get('object_id') and self._context.get('object_name') and self._context.get( + 'explain') and self._context.get('function_name'): + work_sheet_obj = self.env[self._context["object_name"]].search([('id', '=', int(self._context["object_id"]))]) + class_name = self._context.get('object_name') # 获得对象类名 + method_name = self._context.get('function_name') # 获得对象的方法 + obj_function = getattr(self.env[class_name], method_name) + obj_function(work_sheet_obj) diff --git a/sf_base/models/common.py b/sf_base/models/common.py index 6fd7d814..888f3eaf 100644 --- a/sf_base/models/common.py +++ b/sf_base/models/common.py @@ -61,12 +61,10 @@ class MrsMaterialModel(models.Model): supplier_ids = fields.One2many('sf.supplier.sort', 'materials_model_id', string='供应商') active = fields.Boolean('有效', default=True) - @api.onchange('gain_way') - def _check_gain_way(self): - if not self.gain_way: - raise UserError("请选择获取方式") - if self.gain_way in ['外协', '采购']: - if not self.supplier_ids: + @api.constrains("gain_way") + def _check_supplier_ids(self): + for item in self: + if item.gain_way in ('外协', '采购') and not item.supplier_ids: raise UserError("请添加供应商") diff --git a/sf_base/views/common_view.xml b/sf_base/views/common_view.xml index 74e916a8..a7cbcf9e 100644 --- a/sf_base/views/common_view.xml +++ b/sf_base/views/common_view.xml @@ -251,7 +251,7 @@ - + @@ -270,9 +270,9 @@ - + - + diff --git a/sf_bf_connect/models/jd_eclp.py b/sf_bf_connect/models/jd_eclp.py index 7879938e..ec14f7e1 100644 --- a/sf_bf_connect/models/jd_eclp.py +++ b/sf_bf_connect/models/jd_eclp.py @@ -161,6 +161,9 @@ class JdEclp(models.Model): url2 = config['bfm_url'] + '/api/get/jd/no' response = requests.post(url2, json=json2, data=None) # _logger.info('调用成功2', response.json()['result']['wbNo']) + tem_ret = response.json().get('result') + if not tem_ret: + raise ValidationError('京东物流返回异常,请联系管理员') self.carrier_tracking_ref = response.json()['result'].get('wbNo') if not self.carrier_tracking_ref: raise ValidationError('物流下单未成功,请联系管理员') diff --git a/sf_dlm_management/data/stock_data.xml b/sf_dlm_management/data/stock_data.xml index 3d2c4527..be2a36e0 100644 --- a/sf_dlm_management/data/stock_data.xml +++ b/sf_dlm_management/data/stock_data.xml @@ -22,6 +22,16 @@ + + 拆解 + + internal + DJCJ + true + true + + + 刀具组装入库 diff --git a/sf_manufacturing/models/mrp_production.py b/sf_manufacturing/models/mrp_production.py index 22341405..79542016 100644 --- a/sf_manufacturing/models/mrp_production.py +++ b/sf_manufacturing/models/mrp_production.py @@ -124,12 +124,17 @@ class MrpProduction(models.Model): if ( production.state == 'to_close' or production.state == 'progress') and production.schedule_state == '未排': production.state = 'confirmed' + elif production.state == 'pending_cam' and production.schedule_state == '未排': + production.state = 'confirmed' elif production.state == 'to_close' and production.schedule_state == '已排': production.state = 'pending_cam' if production.state == 'progress': if all(wo_state not in ('progress', 'done') for wo_state in production.workorder_ids.mapped('state')): production.state = 'pending_cam' + if production.state == 'pending_cam': + if all(wo_state in 'done' for wo_state in production.workorder_ids.mapped('state')): + production.state = 'done' def action_check(self): """ @@ -699,7 +704,7 @@ class MrpProduction(models.Model): logging.info('qty_produced:%s' % production.qty_produced) production.write({ 'date_finished': fields.Datetime.now(), - 'product_qty': production.product_qty if production.qty_produced < 1.0 else production.qty_produced, + 'product_qty': production.qty_produced, 'priority': '0', 'is_locked': True, 'state': 'done', diff --git a/sf_manufacturing/models/mrp_workcenter.py b/sf_manufacturing/models/mrp_workcenter.py index 7d70ae5e..64cf2d8d 100644 --- a/sf_manufacturing/models/mrp_workcenter.py +++ b/sf_manufacturing/models/mrp_workcenter.py @@ -5,24 +5,49 @@ from odoo.addons.resource.models.resource import Intervals class ResWorkcenter(models.Model): - _inherit = "mrp.workcenter" + _name = "mrp.workcenter" + _inherit = ['mrp.workcenter', 'mail.thread'] # 生产线显示 production_line_show = fields.Char(string='生产线名称') - equipment_id = fields.Many2one( - 'maintenance.equipment', string="设备", - ) + equipment_id = fields.Many2one('maintenance.equipment', string="设备", tracking=True) production_line_id = fields.Many2one('sf.production.line', string='生产线', related='equipment_id.production_line_id', store=True) - is_process_outsourcing = fields.Boolean('工艺外协') - users_ids = fields.Many2many("res.users", 'users_workcenter') + users_ids = fields.Many2many("res.users", 'users_workcenter', tracking=True) + def write(self, vals): + if 'users_ids' in vals: + old_users = self.users_ids + res = super(ResWorkcenter, self).write(vals) + new_users = self.users_ids + added_users = new_users - old_users + removed_users = old_users - new_users + if added_users or removed_users: + message = "增加 → %s ; 移除 → %s (可操作用户)" % ( + # ','.join(added_users.mapped('name')), ','.join(removed_users.mapped('name'))) + added_users.mapped('name'), removed_users.mapped('name')) + self.message_post(body=message) + return res + return super(ResWorkcenter, self).write(vals) + name = fields.Char('Work Center', related='resource_id.name', store=True, readonly=False, tracking=True) + time_efficiency = fields.Float('Time Efficiency', related='resource_id.time_efficiency', default=100, store=True, + readonly=False, tracking=True) + default_capacity = fields.Float( + 'Capacity', default=1.0, + help="Default number of pieces (in product UoM) that can be produced in parallel (at the same time) at this work center. For example: the capacity is 5 and you need to produce 10 units, then the operation time listed on the BOM will be multiplied by two. However, note that both time before and after production will only be counted once.", + tracking=True) + oee_target = fields.Float( + string='OEE Target', help="Overall Effective Efficiency Target in percentage", default=90, tracking=True) + + time_start = fields.Float('Setup Time', tracking=True) + time_stop = fields.Float('Cleanup Time', tracking=True) + costs_hour = fields.Float(string='Cost per hour', help='Hourly processing cost.', default=0.0, tracking=True) equipment_status = fields.Selection( - [("正常", "正常"), ("故障停机", "故障停机"), ("计划维保", "计划维保"),("空闲", "空闲"),("封存(报废)", "封存(报废)")], + [("正常", "正常"), ("故障停机", "故障停机"), ("计划维保", "计划维保"), ("空闲", "空闲"), ("封存(报废)", "封存(报废)")], string="设备状态", related='equipment_id.state') # @api.depends('equipment_id') diff --git a/sf_manufacturing/models/mrp_workorder.py b/sf_manufacturing/models/mrp_workorder.py index 731650af..0e10af72 100644 --- a/sf_manufacturing/models/mrp_workorder.py +++ b/sf_manufacturing/models/mrp_workorder.py @@ -165,7 +165,8 @@ class ResMrpWorkOrder(models.Model): 保存名称 """ for record in self: - record.save_name = record.production_id.name.replace('/', '_') + tem_name = record.production_id.name.replace('/', '_') + record.save_name = tem_name + '_' + record.processing_panel schedule_state = fields.Selection(related='production_id.schedule_state', store=True) # 工件装夹信息 @@ -1039,7 +1040,7 @@ class ResMrpWorkOrder(models.Model): move_raw_id.quantity_done = move_raw_id.product_uom_qty record.process_state = '已完工' record.production_id.process_state = '已完工' - if record.routing_type in ['解除装夹', '表面工艺']: + if record.routing_type in ['表面工艺']: raw_move = self.env['stock.move'].sudo().search( [('origin', '=', record.production_id.name), ('procure_method', 'in', ['make_to_order', 'make_to_stock']), @@ -1115,6 +1116,16 @@ class ResMrpWorkOrder(models.Model): # logging.info('button_send_program_again error:%s' % e) # raise UserError("重新下发nc程序失败,请联系管理员") + def print_method(self): + """ + 解除装夹处调用关联制造订单的关联序列号的打印方法 + """ + if self.production_id: + if self.production_id.lot_producing_id: + self.production_id.lot_producing_id.print_single_method() + else: + raise UserError("无关联制造订单或关联序列号,无法打印。请检查!") + class CNCprocessing(models.Model): _name = 'sf.cnc.processing' diff --git a/sf_manufacturing/models/stock.py b/sf_manufacturing/models/stock.py index 9234060f..a1543b8f 100644 --- a/sf_manufacturing/models/stock.py +++ b/sf_manufacturing/models/stock.py @@ -204,7 +204,8 @@ class StockRule(models.Model): productions = self.env['mrp.production'].with_user(SUPERUSER_ID).sudo().with_company(company_id).create( productions_values) - self.env['stock.move'].sudo().create(productions._get_moves_raw_values()) + # self.env['stock.move'].sudo().create(productions._get_moves_raw_values()) + self.env['stock.move'].sudo().create(productions._get_moves_finished_values()) ''' 创建工单 @@ -404,6 +405,7 @@ class ProductionLot(models.Model): def print_single_method(self): + print('self.name========== %s' % self.name) self.ensure_one() qr_code_data = self.qr_code_image if not qr_code_data: diff --git a/sf_manufacturing/views/mrp_workcenter_views.xml b/sf_manufacturing/views/mrp_workcenter_views.xml index 9aad2a99..ad433bf6 100644 --- a/sf_manufacturing/views/mrp_workcenter_views.xml +++ b/sf_manufacturing/views/mrp_workcenter_views.xml @@ -24,6 +24,20 @@ + + custom.model.form.view.inherit + mrp.workcenter + + + +
+ + +
+
+
+
+ mrp.workcenter.view.kanban.inherit.mrp.workorder mrp.workcenter diff --git a/sf_manufacturing/views/mrp_workorder_view.xml b/sf_manufacturing/views/mrp_workorder_view.xml index 2a6c37b4..68bb9154 100644 --- a/sf_manufacturing/views/mrp_workorder_view.xml +++ b/sf_manufacturing/views/mrp_workorder_view.xml @@ -160,9 +160,19 @@ +

@@ -711,10 +723,14 @@ - - - - + + + + @@ -759,10 +775,25 @@
+
+ + +

@@ -773,12 +804,13 @@ - - + + + @@ -791,8 +823,8 @@ attrs="{'readonly': [('state', '=', '已拆解')]}"/> - + + @@ -879,6 +911,23 @@ + + + + + + + + + + + + + + + diff --git a/sf_tool_management/wizard/wizard.py b/sf_tool_management/wizard/wizard.py index af772016..4edd3617 100644 --- a/sf_tool_management/wizard/wizard.py +++ b/sf_tool_management/wizard/wizard.py @@ -621,26 +621,10 @@ class FunctionalToolAssemblyOrder(models.TransientModel): desc_1 = self.get_desc_1(stock_lot) # 封装功能刀具数据,用于创建功能刀具记录 desc_2 = self.get_desc_2(stock_lot, functional_tool_assembly) - # 创建刀具组装入库单 - self.env['stock.picking'].create_stocking_picking(stock_lot, functional_tool_assembly, self) - # 刀具物料出库 - if self.handle_code_id: - product_id.tool_material_stock_moves(self.handle_code_id, self.assembly_order_code) - if self.integral_product_id: - self.integral_product_id.material_stock_moves(self.integral_freight_barcode_id, - self.integral_freight_lot_id, self.assembly_order_code) - if self.blade_product_id: - self.blade_product_id.material_stock_moves(self.blade_freight_barcode_id, - self.blade_freight_lot_id, self.assembly_order_code) - if self.bar_product_id: - self.bar_product_id.material_stock_moves(self.bar_freight_barcode_id, - self.bar_freight_lot_id, self.assembly_order_code) - if self.pad_product_id: - self.pad_product_id.material_stock_moves(self.pad_freight_barcode_id, - self.pad_freight_lot_id, self.assembly_order_code) - if self.chuck_product_id: - self.chuck_product_id.material_stock_moves(self.chuck_freight_barcode_id, - self.chuck_freight_lot_id, self.assembly_order_code) + # 创建功能刀具组装入库单 + self.env['stock.picking'].create_tool_stocking_picking(stock_lot, functional_tool_assembly, self) + # 创建刀具物料出库单 + self.env['stock.picking'].create_tool_stocking_picking1(self) # ============================创建功能刀具列表、安全库存记录=============================== # 创建功能刀具列表记录 @@ -786,9 +770,9 @@ class FunctionalToolAssemblyOrder(models.TransientModel): class StockPicking(models.Model): _inherit = 'stock.picking' - def create_stocking_picking(self, stock_lot, functional_tool_assembly, obj): + def create_tool_stocking_picking(self, stock_lot, functional_tool_assembly, obj): """ - 创建刀具组装入库单 + 创建功能刀具组装入库单 """ # 获取名称为刀具组装入库的作业类型 picking_type_id = self.env['stock.picking.type'].sudo().search([('name', '=', '刀具组装入库')]) @@ -807,6 +791,7 @@ class StockPicking(models.Model): 'location_id': picking_id.location_id.id, 'location_dest_id': picking_id.location_dest_id.id, 'lot_id': stock_lot.id, + 'install_tool_time': fields.Datetime.now(), 'qty_done': 1, 'functional_tool_name_id': functional_tool_assembly.id, 'functional_tool_type_id': obj.functional_tool_type_id.id, @@ -836,6 +821,101 @@ class StockPicking(models.Model): num = "%03d" % m return name + str(num) + def create_tool_stocking_picking1(self, obj): + """ + 创建刀具物料出库单 + """ + # 获取名称为内部调拨的作业类型 + picking_type_id = self.env['stock.picking.type'].sudo().search([('name', '=', '内部调拨')]) + # 创建刀具物料出库单 + picking_id = self.env['stock.picking'].create({ + 'name': self._get_name_stock1(picking_type_id), + 'picking_type_id': picking_type_id.id, + 'location_id': self.env['stock.location'].search([('name', '=', '刀具房')]).id, + 'location_dest_id': self.env['stock.location'].search([('name', '=', '刀具组装位置')]).id, + 'origin': obj.assembly_order_code + }) + # =============刀具物料出库=================== + stock_move_id = self.env['stock.move'] + datas = {'data': [], 'picking_id': picking_id} + if obj.handle_code_id: + datas['data'].append( + {'current_location_id': self.env['sf.shelf.location'], 'lot_id': obj.handle_code_id}) + if obj.integral_product_id: + datas['data'].append( + {'current_location_id': obj.integral_freight_barcode_id, 'lot_id': obj.integral_freight_lot_id.lot_id}) + if obj.blade_product_id: + datas['data'].append( + {'current_location_id': obj.blade_freight_barcode_id, 'lot_id': obj.blade_freight_lot_id.lot_id}) + if obj.bar_product_id: + datas['data'].append( + {'current_location_id': obj.bar_freight_barcode_id, 'lot_id': obj.bar_freight_lot_id.lot_id}) + if obj.pad_product_id: + datas['data'].append( + {'current_location_id': obj.pad_freight_barcode_id, 'lot_id': obj.pad_freight_lot_id.lot_id}) + if obj.chuck_product_id: + datas['data'].append( + {'current_location_id': obj.chuck_freight_barcode_id, 'lot_id': obj.chuck_freight_lot_id.lot_id}) + # 创建刀具物料出库库存移动记录 + stock_move_id.create_tool_material_stock_moves(datas) + # 将刀具物料出库库单的状态更改为就绪 + picking_id.action_confirm() + # 修改刀具物料出库移动历史记录 + stock_move_id.write_tool_material_stock_move_lines(datas) + # 设置数量,并验证完成 + picking_id.action_set_quantities_to_reservation() + picking_id.button_validate() + + def _get_name_stock1(self, picking_type_id): + name = picking_type_id.sequence_id.prefix + stock_id = self.env['stock.picking'].sudo().search( + [('name', 'like', name), ('picking_type_id', '=', picking_type_id.id)], + limit=1, + order="id desc" + ) + if not stock_id: + num = "%05d" % 1 + else: + m = int(stock_id.name[-3:]) + 1 + num = "%05d" % m + return name + str(num) + + +class StockMove(models.Model): + _inherit = 'stock.move' + + def create_tool_material_stock_moves(self, datas): + picking_id = datas['picking_id'] + data = datas['data'] + stock_move_ids = [] + for res in data: + if res: + # 创建库存移动记录 + stock_move_id = self.env['stock.move'].sudo().create({ + 'name': picking_id.name, + 'picking_id': picking_id.id, + 'product_id': res['lot_id'].product_id.id, + 'location_id': picking_id.location_id.id, + 'location_dest_id': picking_id.location_dest_id.id, + 'product_uom_qty': 1.00, + 'reserved_availability': 1.00 + }) + stock_move_ids.append(stock_move_id) + return stock_move_ids + + def write_tool_material_stock_move_lines(self, datas): + picking_id = datas['picking_id'] + data = datas['data'] + move_line_ids = picking_id.move_line_ids + for move_line_id in move_line_ids: + for res in data: + if move_line_id.lot_id.product_id == res['lot_id'].product_id: + move_line_id.write({ + 'current_location_id': res.get('current_location_id').id, + 'lot_id': res.get('lot_id').id + }) + return True + class ProductProduct(models.Model): _inherit = 'product.product' @@ -853,13 +933,6 @@ class ProductProduct(models.Model): 'product_id': product_id[0].id, 'company_id': self.env.company.id }) - # 获取位置对象 - location_inventory_ids = self.env['stock.location'].search([('name', 'in', ('Production', '生产'))]) - stock_location_id = self.env['stock.location'].search([('name', '=', '组装后')]) - # 创建功能刀具该批次/序列号 库存移动和移动历史 - stock_lot.create_stock_quant(location_inventory_ids[-1], stock_location_id, functional_tool_assembly.id, - obj.assembly_order_code, obj, obj.after_tool_groups_id) - return stock_lot def get_stock_lot_name(self, obj): @@ -877,87 +950,3 @@ class ProductProduct(models.Model): m = int(stock_lot_id.name[-3:]) + 1 num = "%03d" % m return '%s-%s' % (code, num) - - def tool_material_stock_moves(self, tool_material, assembly_order_code): - """ - 对刀具物料进行库存移动到 刀具组装位置 - """ - # 获取位置对象 - location_inventory_id = tool_material.quant_ids.location_id[-1] - stock_location_id = self.env['stock.location'].search([('name', '=', '刀具组装位置')]) - # 创建功能刀具该批次/序列号 库存移动和移动历史 - tool_material.create_stock_quant(location_inventory_id, stock_location_id, None, assembly_order_code, False, - False) - - def material_stock_moves(self, shelf_location_barcode_id, lot_id, assembly_order_code): - # 创建库存移动记录 - stock_move_id = self.env['stock.move'].sudo().create({ - 'name': assembly_order_code, - 'product_id': self.id, - 'location_id': self.env['stock.location'].search([('name', '=', '刀具房')]).id, - 'location_dest_id': self.env['stock.location'].search([('name', '=', '刀具组装位置')]).id, - 'product_uom_qty': 1.00, - 'state': 'done' - }) - - # 创建移动历史记录 - stock_move_line_id = self.env['stock.move.line'].sudo().create({ - 'product_id': self.id, - 'move_id': stock_move_id.id, - 'lot_id': lot_id.lot_id.id, - 'current_location_id': shelf_location_barcode_id.id, - 'install_tool_time': fields.Datetime.now(), - 'qty_done': 1.0, - 'state': 'done', - }) - return stock_move_id, stock_move_line_id - - -class StockLot(models.Model): - _inherit = 'stock.lot' - - def create_stock_quant(self, location_inventory_id, stock_location_id, functional_tool_assembly_id, name, obj, - tool_groups_id): - """ - 对功能刀具组装过程的功能刀具和刀具物料进行库存移动,以及创建移动历史 - """ - - # 创建库存移动记录 - stock_move_id = self.env['stock.move'].sudo().create({ - 'name': name, - 'product_id': self.product_id.id, - 'location_id': location_inventory_id.id, - 'location_dest_id': stock_location_id.id, - 'product_uom_qty': 1.00, - 'state': 'done' - }) - - # 创建移动历史记录 - stock_move_line_id = self.env['stock.move.line'].sudo().create({ - 'product_id': self.product_id.id, - 'functional_tool_name_id': functional_tool_assembly_id, - 'lot_id': self.id, - 'move_id': stock_move_id.id, - 'install_tool_time': fields.Datetime.now(), - 'qty_done': 1.0, - 'state': 'done', - 'functional_tool_type_id': False if not obj else obj.functional_tool_type_id.id, - 'diameter': None if not obj else obj.after_assembly_functional_tool_diameter, - 'knife_tip_r_angle': None if not obj else obj.after_assembly_knife_tip_r_angle, - 'code': '' if not obj else obj.code, - 'rfid': '' if not obj else obj.rfid, - 'functional_tool_name': '' if not obj else obj.after_assembly_functional_tool_name, - 'tool_groups_id': False if not tool_groups_id else tool_groups_id.id - }) - return stock_move_id, stock_move_line_id - -# class StockQuant(models.Model): -# _inherit = 'stock.quant' -# -# @api.model_create_multi -# def create(self, vals_list): -# records = super(StockQuant, self).create(vals_list) -# for record in records: -# if record.lot_id.product_id.categ_id.name == '刀具': -# record.lot_id.enroll_tool_material_stock() -# return records diff --git a/sg_wechat_enterprise/.gitignore b/sg_wechat_enterprise/.gitignore new file mode 100644 index 00000000..a81c8ee1 --- /dev/null +++ b/sg_wechat_enterprise/.gitignore @@ -0,0 +1,138 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +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/ diff --git a/sg_wechat_enterprise/__init__.py b/sg_wechat_enterprise/__init__.py new file mode 100644 index 00000000..4589499c --- /dev/null +++ b/sg_wechat_enterprise/__init__.py @@ -0,0 +1,3 @@ +from . import we_api +from . import models +from . import controllers diff --git a/sg_wechat_enterprise/__manifest__.py b/sg_wechat_enterprise/__manifest__.py new file mode 100644 index 00000000..883747fd --- /dev/null +++ b/sg_wechat_enterprise/__manifest__.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +{ + 'name': '企业微信', + 'version': '0.1', + 'summary': '企业通讯录\消息处理\企业应用\无缝登录', + 'sequence': 30, + "author": 'SmartGo Studio.,', + 'description': '''用于企业内部员工的管理, +ER企业微信模块 +===================================================== +主要针对odoo使用微信进行管理,包括以下功能: +1) 公众号信息管理(企业号下多applicaiton管理) +2) 接收消息处理 +3) 发送消息处理 +4) 自定义菜单处理 + +.... +本安装包使用了WechatEnterpriseSDK/wechat_sdk.py,在此表示感谢。 +源代码可以访问github.地址如下:https://github.com/facert/WechatEnterpriseSDK + + ''', + 'category': '基础信息', + 'website': 'https://www.smartgo.cn', + 'depends': ['base', 'mail','hr'], + 'data': [ + 'security/ir.model.access.csv', + 'views/we_config_view.xml', + 'views/we_app_view.xml', + 'views/we_send_message_view.xml', + 'views/we_receive_message_view.xml', + 'views/we_message_process_view.xml', + 'views/we_templates.xml', + # "views/mail_view.xml", + "views/res_users_view.xml", + 'views/menu_view.xml', + # 'views/we_menu.xml', + "data/data.xml" + ], + 'demo': [ + 'demo/we_config_demo.xml', + ], + 'qweb': [ + # "static/src/xml/base.xml", + # "static/src/xml/account_payment.xml", + # "static/src/xml/account_report_backend.xml", + ], + 'installable': True, + 'application': True, + 'auto_install': False, + # 'post_init_hook': '_auto_install_l10n', +} diff --git a/sg_wechat_enterprise/controllers/WXBizMsgCrypt.py b/sg_wechat_enterprise/controllers/WXBizMsgCrypt.py new file mode 100644 index 00000000..820a9c96 --- /dev/null +++ b/sg_wechat_enterprise/controllers/WXBizMsgCrypt.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python +# -*- encoding:utf-8 -*- + +""" 对企业微信发送给企业后台的消息加解密示例代码. +@copyright: Copyright (c) 1998-2014 Tencent Inc. + +""" +# ------------------------------------------------------------------------ + +import base64 +import string +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() + sort_str = "".join(sortlist) + sha.update(sort_str.encode('utf-8')) + return ierror.WXBizMsgCrypt_OK, sha.hexdigest() + except Exception as e: + return ierror.WXBizMsgCrypt_ComputeSignature_Error, None + + +class XMLParse: + """提供提取消息格式中的密文及生成回复消息格式的接口""" + + # xml消息模板 + AES_TEXT_RESPONSE_TEMPLATE = """ + + +%(timestamp)s + +""" + + def extract(self, xmltext): + """提取出xml数据包中的加密消息 + @param xmltext: 待提取的xml字符串 + @return: 提取出的加密消息字符串 + """ + try: + xml_tree = ET.fromstring(xmltext) + encrypt = xml_tree.find("Encrypt") + touser_name = xml_tree.find("ToUserName") + return ierror.WXBizMsgCrypt_OK, encrypt.text, touser_name.text + except Exception as e: + return ierror.WXBizMsgCrypt_ParseXml_Error, None, 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 + + 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, corpid): + """对明文进行加密 + @param text: 需要加密的明文 + @return: 加密得到的字符串 + """ + # 16位随机字符串添加到明文开头 + text = self.get_random_str() + str(struct.pack("I", socket.htonl(len(text))), encoding='utf8')\ + + str(text, encoding='utf8') + corpid + # 使用自定义的填充方式对明文进行补位填充 + 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: + return ierror.WXBizMsgCrypt_EncryptAES_Error, None + + def decrypt(self, text, corpid): + """对解密后的明文进行补位删除 + @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: + 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_corpid = content[xml_len + 4:] + except Exception as e: + return ierror.WXBizMsgCrypt_IllegalBuffer, None + if str(from_corpid, encoding="utf8") != corpid and len(ET.fromstring(xml_content).findall("SuiteId")) < 1: + return ierror.WXBizMsgCrypt_ValidateCorpid_Error, None + return 0, xml_content + + def get_random_str(self): + """ 随机生成16位字符串 + @return: 16位字符串 + """ + rule = string.ascii_letters + string.digits + str = random.sample(rule, 16) + return "".join(str) + + +class WXBizMsgCrypt(object): + # 构造函数 + # @param sToken: 企业微信后台,开发者设置的Token + # @param sEncodingAESKey: 企业微信后台,开发者设置的EncodingAESKey + # @param sCorpId: 企业号的CorpId + def __init__(self, sToken, sEncodingAESKey, sCorpId): + 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_sCorpid = sCorpId + + # 验证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_sCorpid) + 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_sCorpid) + 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, touser_name = 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_sCorpid) + return ret, xml_content diff --git a/sg_wechat_enterprise/controllers/__init__.py b/sg_wechat_enterprise/controllers/__init__.py new file mode 100644 index 00000000..0b1b2d96 --- /dev/null +++ b/sg_wechat_enterprise/controllers/__init__.py @@ -0,0 +1,3 @@ + +from . import wechat_enterprise + diff --git a/sg_wechat_enterprise/controllers/ierror.py b/sg_wechat_enterprise/controllers/ierror.py new file mode 100644 index 00000000..6678fecf --- /dev/null +++ b/sg_wechat_enterprise/controllers/ierror.py @@ -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 diff --git a/sg_wechat_enterprise/controllers/wechat_enterprise.py b/sg_wechat_enterprise/controllers/wechat_enterprise.py new file mode 100644 index 00000000..0c7f8738 --- /dev/null +++ b/sg_wechat_enterprise/controllers/wechat_enterprise.py @@ -0,0 +1,231 @@ +import time +import functools +import base64 +from json import * + +from odoo import http, fields +from odoo.http import request +from . import WXBizMsgCrypt +from werkzeug.exceptions import abort +import xml.etree.cElementTree as Et +import requests as req + +import logging +from lxml import etree + + +_logger = logging.getLogger(__name__) + + +def wechat_login(func): + """ + 用来根据userid取得合作伙伴的id + :return: + """ + + @functools.wraps(func) + def wrapper(*args, **kw): + # now_time = time.time() + # request.session['session_time'] = time.time() + # if 'session_time' not in request.session: + # request.session['session_time'] = 1 + # if now_time > request.session['session_time'] + 350: + _logger.info(u"没进来之前的kw值为%s" % JSONEncoder().encode(kw)) + # if not request.session['login'] or not request.session['login'] or request.session[ + # 'login'] == "public": + enterprise, agent_id = request.env['we.config'].sudo().get_odoo_wechat() + if enterprise and agent_id: + if 'code' in kw and 'state' in kw: # 检查是否取得了code + if 'kw' in request.session.keys(): + _logger.info('code:%s' % kw['code']) + account = enterprise.oauth.get_user_info(code=kw['code']) + _logger.info('account:%s' % account) + if account: # 是否取得了微信企业号通讯录的账号 + user_detail = enterprise.user.get_detail(account['user_ticket']) + _logger.info('user_detail:%s' % user_detail) + request.env['we.employee'].we_privacy_update(user_detail) + user = request.env['res.users'].sudo().search( + [('we_employee_id', '=', account['UserId'])]) + _logger.info('user:%s' % user) + if user: # 是否取得了用户 + if 'state' in kw: + state = base64.b64encode(kw['state'].encode('utf-8')).decode() + kw['state'] = state + uid = request.session.authenticate(request.session.db, user.login, + account['UserId']) + kw['user_id'] = uid + request.session['session_time'] = time.time() + request.session['login'] = user.login + + _logger.info(u"进来之后的kw值为%s" % kw) + return func(*args, **kw) + else: + _logger.warning(u'用户不存在.') + return request.render('sg_wechat_enterprise.wechat_warning', + {'title': u'警告', 'content': u'该员工的未配置登录用户.'}) + else: + _logger.warning(u'微信企业号验证失败.') + return request.render('sg_wechat_enterprise.wechat_warning', + {'title': u'警告', 'content': u'微信企业号验证失败.'}) + else: + # 返回时候进入的地方 + del kw['code'] + del kw['state'] + request.session['kw'] = base64.b64encode(JSONEncoder().encode(kw).encode('utf-8')).decode() + if len(kw) == 0: + base_url = request.httprequest.base_url + else: + base_url = request.httprequest.base_url + '?' + for item, value in kw.items(): + base_url += item + '=' + value + "&" + base_url = base_url[: -1] + + url = enterprise.oauth.authorize_url(base_url, + state=base64.b64encode( + JSONEncoder().encode(kw).encode('utf-8')).decode(), + agent_id=agent_id, + scope='snsapi_privateinfo') + _logger.warning(u"这是授权的url:" + url) + value = {"url": url} + return request.render("sg_wechat_enterprise.Transfer", value) + else: # 开始微信企业号登录认证 + request.session['kw'] = base64.b64encode(JSONEncoder().encode(kw).encode('utf-8')).decode() + + if len(kw) == 0: + base_url = request.httprequest.base_url + else: + base_url = request.httprequest.base_url + '?' + for item, value in kw.items(): + base_url += item + '=' + value + "&" + base_url = base_url[: -1] + url = enterprise.oauth.authorize_url(base_url, + state=base64.b64encode( + JSONEncoder().encode(kw).encode('utf-8')).decode(), + agent_id=agent_id, + scope='snsapi_privateinfo' + ) + _logger.warning(u"这是授权的url:" + url) + value = {"url": url} + return request.render("sg_wechat_enterprise.Transfer", value) + else: + _logger.warning(u'微信企业号初始化失败.') + return request.render('sg_wechat_enterprise.wechat_warning', + {'title': u'警告', 'content': u'微信企业号初始化失败.'}) + + # return func(*args, **kw) + + return wrapper + + +class WechatEnterprise(http.Controller): + """ + 用于接收微信发过来的任何消息,并转发给相应的业务类进行处理 + """ + __check_str = 'NDOEHNDSY#$_@$JFDK:Q{!' + BASE_URL = '/we' + + @wechat_login + @http.route(BASE_URL + '/auth', type='http', auth='none') + def auth(self, *args, **kw): + """ + 企业微信免登认证 + """ + try: + # user_id = (request.session['uid']) + redirect1 = kw['redirect'] if 'redirect' in kw else None + uid = kw['user_id'] if 'redirect' in kw else None + _logger.info('user_id %s', uid) + if uid is not False: + request.params['login_success'] = True + if not redirect1: + redirect1 = '/web' + redirect1 = redirect1.replace('-', '&').replace('?', '#') + logging.info('url:%s' % redirect1) + return request.redirect(redirect1) + except Exception as ex: + _logger.error('无有效的登录凭证.') + _logger.warning('auth exceptions:%s' % ex) + return request.render('sg_wechat_enterprise.wechat_warning', + {'title': u'警告', 'content': u'无有效的登录凭证.'}) + + @http.route('/WechatEnterprise//api', type='http', auth="public", methods=["GET", "POST"], csrf=False) + def process(self, code, **kwargs): + """ + 处理从微信服务器发送过来的请求 + :param code: 自定义代码 + :param kwargs: 包含 (msg_signature, timestamp, nonce, echostr) 等参数 + :return: + """ + _logger.info(u'处理从微信服务器发送过来的请求code: %s, kwargs: %s' % (code, kwargs)) + app_id = request.env['we.app'].sudo().search([('code', '=', code)], limit=1) + if not app_id: + _logger.warning(u'Can not find wechat app by code: {code}') + abort(403) + corp_id = app_id.enterprise_id + we_chat_cpt = WXBizMsgCrypt.WXBizMsgCrypt(app_id.Token, app_id.EncodingAESKey, corp_id.corp_id) + signature, timestamp, nonce = kwargs['msg_signature'], kwargs['timestamp'], kwargs['nonce'] + if kwargs.get('echostr'): + echo_string = kwargs['echostr'] + sort_list = [app_id.Token, timestamp, nonce, echo_string] + if request.env['we.tools'].sudo(). \ + check_message_signature(message_list=sort_list, msg_signature=signature): + ret, signature_echo_string = we_chat_cpt.VerifyURL(signature, timestamp, nonce, echo_string) + if ret == 0: + return str(signature_echo_string, encoding="utf8") + body_text = request.httprequest.data + ret, signature_message = we_chat_cpt.DecryptMsg(body_text, signature, timestamp, nonce) + xml_tree = Et.fromstring(signature_message) + if len(xml_tree.findall("SuiteId")) > 0: + return "success" + if len(xml_tree.find("ApprovalInfo")) > 0: + xmlstr = etree.fromstring(signature_message) + # data = xml2json_from_elementtree(xmlstr) + return request.env['we.receive.message'].sudo().sys_approval_change(xml_tree.find("ApprovalInfo")) + data = { + 'MsgType': xml_tree.find("MsgType").text, + # 'AgentID': xml_tree.find("AgentID").text, + 'ToUserName': xml_tree.find("ToUserName").text, + 'FromUserName': xml_tree.find("FromUserName").text, + 'CreateTime': xml_tree.find("CreateTime").text + } + if xml_tree.find("AgentID") != None: + data['AgentID'] = xml_tree.find("AgentID").text + if data["MsgType"] == "text": + data["Content"] = xml_tree.find("Content").text + data["MsgId"] = xml_tree.find("MsgId").text + if data["MsgType"] == "image": + data["PicUrl"] = xml_tree.find("PicUrl").text + data["MediaId"] = xml_tree.find("MediaId").text + data["MsgId"] = xml_tree.find("MsgId").text + if data["MsgType"] == "voice": + data["MediaId"] = xml_tree.find("MediaId").text + data["Format"] = xml_tree.find("Format").text + data["MsgId"] = xml_tree.find("MsgId").text + if data["MsgType"] == "video" or data["MsgType"] == "shortvideo": + data["MediaId"] = xml_tree.find("MediaId").text + data["ThumbMediaId"] = xml_tree.find("ThumbMediaId").text + data["MsgId"] = xml_tree.find("MsgId").text + if data["MsgType"] == "location": + data["Location_X"] = xml_tree.find("Location_X").text + data["Location_Y"] = xml_tree.find("Location_Y").text + data["Scale"] = xml_tree.find("Scale").text + data["Label"] = xml_tree.find("Label").text + data["MsgId"] = xml_tree.find("MsgId").text + if data["MsgType"] == "link": + data["Title"] = xml_tree.find("Title").text + data["Description"] = xml_tree.find("Description").text + data["PicUrl"] = xml_tree.find("PicUrl").text + data["MsgId"] = xml_tree.find("MsgId").text + if data["MsgType"] == "event": + if xml_tree.find("Event") == "subscribe" or xml_tree.find("Event") == "unsubscribe": + data["Event"] = xml_tree.find("Event").text + else: + ret, signature_message = we_chat_cpt.EncryptMsg(signature_message, nonce, timestamp) + return signature_message + request.env['we.receive.message'].sudo().process_message(data) + return '' + + @http.route('/WechatEnterprise/transfer', type='http', auth="public", methods=["POST", "GET"], csrf=False) + def transfer(self, url): + value = {"url": url} + return request.render('sg_wechat_enterprise.Transfer', value) diff --git a/sg_wechat_enterprise/data/data.xml b/sg_wechat_enterprise/data/data.xml new file mode 100644 index 00000000..bebcfa10 --- /dev/null +++ b/sg_wechat_enterprise/data/data.xml @@ -0,0 +1,16 @@ + + + + SNS Message To 企业微信 + + code + model.send_we_message() + 1 + minutes + -1 + + + + + + \ No newline at end of file diff --git a/sg_wechat_enterprise/demo/we_config_demo.xml b/sg_wechat_enterprise/demo/we_config_demo.xml new file mode 100644 index 00000000..605bdbd9 --- /dev/null +++ b/sg_wechat_enterprise/demo/we_config_demo.xml @@ -0,0 +1,13 @@ + + + + + SmartGo + wwad4f2c227d490637 + kq_AzJN1FoPdWjyEwAQs_cqzJhALmKhmwYMBQyJzuEs + 1 + + + + + diff --git a/sg_wechat_enterprise/models/__init__.py b/sg_wechat_enterprise/models/__init__.py new file mode 100644 index 00000000..b8fb8a02 --- /dev/null +++ b/sg_wechat_enterprise/models/__init__.py @@ -0,0 +1,9 @@ +from . import we_app +from . import we_conf +from . import we_send_message +from . import we_receive_message +from . import we_receive_message_process +from . import res_users +from . import we_tools +from . import mail +from . import client diff --git a/sg_wechat_enterprise/models/client/__init__.py b/sg_wechat_enterprise/models/client/__init__.py new file mode 100644 index 00000000..72bca418 --- /dev/null +++ b/sg_wechat_enterprise/models/client/__init__.py @@ -0,0 +1,11 @@ +from wechatpy.enterprise import WeChatClient + +from . import api + + +class JkmWeChatClient(WeChatClient): + def __init__(self, corp_id, secret, session=None): + super(JkmWeChatClient, self).__init__(corp_id, secret, session=session) + + oauth = api.JkmWechatOauth() + user = api.JkmWechatUser() diff --git a/sg_wechat_enterprise/models/client/api/__init__.py b/sg_wechat_enterprise/models/client/api/__init__.py new file mode 100644 index 00000000..f50e1972 --- /dev/null +++ b/sg_wechat_enterprise/models/client/api/__init__.py @@ -0,0 +1,2 @@ +from .jkm_user import JkmWechatUser +from .jkm_oauth import JkmWechatOauth diff --git a/sg_wechat_enterprise/models/client/api/jkm_oauth.py b/sg_wechat_enterprise/models/client/api/jkm_oauth.py new file mode 100644 index 00000000..3d491389 --- /dev/null +++ b/sg_wechat_enterprise/models/client/api/jkm_oauth.py @@ -0,0 +1,53 @@ +from __future__ import absolute_import, unicode_literals +import six +from wechatpy.client.api.base import BaseWeChatAPI + + +class JkmWechatOauth(BaseWeChatAPI): + OAUTH_BASE_URL = 'https://open.weixin.qq.com/connect/oauth2/authorize' + + def authorize_url(self, redirect_uri, state=None, agent_id=None, scope='snsapi_base'): + """ + 构造网页授权链接 + 详情请参考 + https://work.weixin.qq.com/api/doc#90000/90135/91022 + + :param redirect_uri: 授权后重定向的回调链接地址 + :param state: 重定向后会带上 state 参数 + :param agent_id: 企业应用的id + :param scope: 应用授权作用域 + :return: 返回的 JSON 数据包 + """ + redirect_uri = six.moves.urllib.parse.quote(redirect_uri, safe=b'') + url_list = [ + self.OAUTH_BASE_URL, + '?appid=', + self._client.corp_id, + '&redirect_uri=', + redirect_uri, + '&response_type=code&scope=', + scope, + ] + if state: + url_list.extend(['&state=', state]) + url_list.append('#wechat_redirect') + if agent_id: + url_list.extend(['&agentid=', str(agent_id)]) + return ''.join(url_list) + + def get_user_info(self, code): + """ + 获取访问用户身份 + 详情请参考 + https://work.weixin.qq.com/api/doc#90000/90135/91023 + + :param code: 通过成员授权获取到的code + :return: 返回的 JSON 数据包 + """ + + return self._get( + 'user/getuserinfo', + params={ + 'code': code, + } + ) diff --git a/sg_wechat_enterprise/models/client/api/jkm_user.py b/sg_wechat_enterprise/models/client/api/jkm_user.py new file mode 100644 index 00000000..44a8c616 --- /dev/null +++ b/sg_wechat_enterprise/models/client/api/jkm_user.py @@ -0,0 +1,21 @@ +from wechatpy.enterprise.client.api import WeChatUser + + +class JkmWechatUser(WeChatUser): + + def get_detail(self, user_ticket): + """ + 获取访问用户敏感信息 + 详情请参考 + https://developer.work.weixin.qq.com/document/path/95833 + + :param user_ticket: 成员票据 + :return: 返回的 JSON 数据包 + """ + return self._post( + 'user/getuserdetail', + data={ + 'user_ticket': user_ticket + } + ) + diff --git a/sg_wechat_enterprise/models/mail.py b/sg_wechat_enterprise/models/mail.py new file mode 100644 index 00000000..7a0c91b4 --- /dev/null +++ b/sg_wechat_enterprise/models/mail.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields, api +import logging + +_logger = logging.getLogger(__name__) + +# -*- coding: utf-8-*- +import re +##过滤HTML中的标签 +#将HTML中标签等信息去掉 +#@param htmlstr HTML字符串. +def filter_tags(htmlstr): + #先过滤CDATA + re_cdata=re.compile('//]*//\]\]>',re.I) #匹配CDATA + re_script=re.compile('<\s*script[^>]*>[^<]*<\s*/\s*script\s*>',re.I)#Script + re_style=re.compile('<\s*style[^>]*>[^<]*<\s*/\s*style\s*>',re.I)#style + re_br=re.compile('')#处理换行 + re_h=re.compile(']*>')#HTML标签 + re_comment=re.compile('')#HTML注释 + s=re_cdata.sub('',htmlstr)#去掉CDATA + s=re_script.sub('',s) #去掉SCRIPT + s=re_style.sub('',s)#去掉style + s=re_br.sub('\n',s)#将br转换为换行 + s=re_h.sub('',s) #去掉HTML 标签 + s=re_comment.sub('',s)#去掉HTML注释 + #去掉多余的空行 + blank_line=re.compile('\n+') + s=blank_line.sub('\n',s) + s=replaceCharEntity(s)#替换实体 + return s + +##替换常用HTML字符实体. +#使用正常的字符替换HTML中特殊的字符实体. +#你可以添加新的实体字符到CHAR_ENTITIES中,处理更多HTML字符实体. +#@param htmlstr HTML字符串. +def replaceCharEntity(htmlstr): + CHAR_ENTITIES={'nbsp':' ','160':' ', + 'lt':'<','60':'<', + 'gt':'>','62':'>', + 'amp':'&','38':'&', + 'quot':'"','34':'"',} + + re_charEntity=re.compile(r'&#?(?P\w+);') + sz=re_charEntity.search(htmlstr) + while sz: + entity=sz.group()#entity全称,如> + key=sz.group('name')#去除&;后entity,如>为gt + try: + htmlstr=re_charEntity.sub(CHAR_ENTITIES[key],htmlstr,1) + sz=re_charEntity.search(htmlstr) + except KeyError: + #以空串代替 + htmlstr=re_charEntity.sub('',htmlstr,1) + sz=re_charEntity.search(htmlstr) + return htmlstr + +def repalce(s,re_exp,repl_string): + return re_exp.sub(repl_string,s) + + +class MailMessage(models.Model): + _inherit = 'mail.message' + + we_is_send = fields.Selection([('-1', '不需要发送'), ('0', '需要发送'), ('1', '已发送')], string='订订消息发送状态', default='0', + index=True) + we_error_msg = fields.Char('消息不发送原因') + + def send_we_message(self): + """ + 定时批量发送钉钉消息 + :return: + """ + messages = self.search([('we_is_send', '=', '0')]) + if messages and len(messages) > 0: + try: + messages.send_message_to_we() + except Exception as ex: + pass + + def send_message_to_we(self): + """ + :param agent_id: 必填,企业应用的id,整型。可在应用的设置页面查看。 + :param user_ids: 成员ID列表(消息接收者,多个接收者用‘|’分隔,最多支持1000个)。 + :param title: 标题,不超过128个字节,超过会自动截断。 + :param description: 必填,描述,不超过512个字节,超过会自动截断 + :param url: 必填,点击后跳转的链接。 + :param btntxt: 按钮文字。 默认为“详情”, 不超过4个文字,超过自动截断。 + :param party_ids: 部门ID列表。 + :param tag_ids: 标签ID列表。 + :return: + """ + # 获取配置信息 + config = self.env['ir.config_parameter'].sudo() + # 获取wechat + wechat,agent_id = self.env['we.config'].sudo().get_odoo_wechat() + for m in self: + we_user = m.get_we_user() + if we_user and len(we_user)>0: + try: + _logger.info('wechat:%s' % wechat) + model = self.env['ir.model'].search([('model', '=', m.model)], limit=1) + title = "[%s] %s" % (model.name, m.record_name or m.subject or '') + description = filter_tags(m.body) if len(filter_tags(m.body)) > 0 else '有新的状态和数据变更,请注意跟进.' + url = '{base_url}/web#id={id}&model={model}&view_type=form'.format( + base_url=config.get_param('web.base.url'), + id=m.res_id, + model=m.model) + _logger.info('description:%s title:%s' % (description, title)) + ret = wechat.message.send_text_card(agent_id, we_user , title, description, url, btntxt='点此跟进..', + party_ids='', tag_ids='') + _logger.info('message_id:%s ret:%s' % (m.message_id, ret)) + m.write({'we_is_send':'1','we_error_msg':''}) + except Exception as ex: + m.we_error_msg = '向企业微信发送消息失败! 消息编号:%s 原因:%s' % (m.message_id, ex) + _logger.error('向企业微信发送消息失败! 消息编号:%s 原因:%s' % (m.message_id, ex)) + else: + m.write({'we_is_send': '-1', 'we_error_msg': '无法发送,没有收件人!'}) + _logger.info('message_id:%s 不需要发送,没有收件人!' % m.message_id) + + + return False \ No newline at end of file diff --git a/sg_wechat_enterprise/models/res_users.py b/sg_wechat_enterprise/models/res_users.py new file mode 100644 index 00000000..275d3bdb --- /dev/null +++ b/sg_wechat_enterprise/models/res_users.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +import random +from odoo import models, fields, api +from odoo.http import request +from odoo.exceptions import AccessDenied + +import logging + +_logger = logging.getLogger(__name__) + + +class ResUsers(models.Model): + _inherit = 'res.users' + + we_employee_id = fields.Char(string=u'企业微信账号', default="") + + def _check_credentials(self, we_employee_id, env): + """ + 用户验证 + """ + try: + return super(ResUsers, self)._check_credentials(we_employee_id, env) + except AccessDenied: + user_id = self.env['res.users'].sudo().search([('we_employee_id', '=', we_employee_id)]) + if not (user_id and user_id.id == self.env.uid): + raise diff --git a/sg_wechat_enterprise/models/we_app.py b/sg_wechat_enterprise/models/we_app.py new file mode 100644 index 00000000..c4ea91ef --- /dev/null +++ b/sg_wechat_enterprise/models/we_app.py @@ -0,0 +1,213 @@ +from odoo import api, models, exceptions, fields +import logging + +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +class WechatEnterpriseApp(models.Model): + _name = 'we.app' + _description = 'Wechat Enterprise App Manage' + + agentid = fields.Integer(string=u'应用ID', required=True) + code = fields.Char(string=u'自定义代码') + name = fields.Char(u'企业号应用名称') + description = fields.Text(u'企业号应用详情') + redirect_domain = fields.Char(u'企业应用可信域名') + isreportuser = fields.Selection([('0', u'不接受'), ('1', u'接收')], u'变更通知', required=True, default='0') + isreportenter = fields.Selection([('0', u'不接受'), + ('1', u'接收')], u'进入应用事件上报', required=True, default='0') + + enterprise_id = fields.Many2one('we.config', string=u'企业微信') + + secret = fields.Char(string=u'Secret', size=100) + home_url = fields.Char(u'主页型应用url') + square_logo_url = fields.Char(u'方形头像url') + round_logo_url = fields.Char(u'圆形头像url') + type = fields.Selection([('1', u'消息型'), ('2', u'主页型')], u'应用类型', required=True, default='1') + allow_userinfos = fields.Char(u'企业应用可见范围(人员)') + allow_partys = fields.Char(u'企业应用可见范围(部门)') + allow_tags = fields.Char(u'企业应用可见范围(标签)') + report_location_flag = fields.Selection([('0', u'不上报'), ('1', u'进入会话上报'), ('2', u'持续上报')], u'位置上报', + required=True, default='1') + logo_mediaid = fields.Char(u'企业应用头像的mediaid') + close = fields.Selection([('0', u'否'), ('1', u'是')], u'是否禁用', required=True, default='0') + app_menu_ids = fields.One2many('we.app.menu', 'agentid', string=u'自定义菜单') + Token = fields.Char(u'Token') + EncodingAESKey = fields.Char(u'EncodingAESKey') + + def pull_app_from_we(self, wechat): + """ + 从微信获取app列表 + :return: + """ + try: + app_lists = wechat.agent.list() + if app_lists: + for app_list in app_lists: + if 'agentid' in app_list: + app_detail = wechat.agent.get(app_list['agentid']) + if app_detail: + data = { + 'agentid': str(app_detail['agentid']) + } + my_app = request.env["we.app"].search( + [("agentid", "=", str(app_detail['agentid']))]) + if my_app and len(my_app) > 0: + continue + data['name'] = app_detail['name'] + data['square_logo_url'] = app_detail['square_logo_url'] + data['description'] = app_detail['description'] + data['close'] = str(app_detail['close']) + data['redirect_domain'] = app_detail['redirect_domain'] + data['report_location_flag'] = str(app_detail['report_location_flag']) + data['isreportenter'] = str(app_detail['isreportenter']) + data['enterprise_id'] = self.id + request.env["we.app"].create(data) + + except Exception as e: + raise Warning((u'获取应用列表失败,原因:%s', str(e))) + + def push_app_to_we(self): + """ + 同步app到微信 + :return: + """ + wechat = self.env['we.config'].sudo().get_wechat() + if wechat: + try: + for account in self: + app_json = { + 'name': account.name + } + if account.description: + app_json['description'] = account.description + if account.redirect_domain: + app_json['redirect_domain'] = account.redirect_domain + app_json['agentid'] = int(account.agentid) + app_json['report_location_flag'] = int(account.report_location_flag) + if account.type == "1": # 消息型应用 + if account.name and account.agentid \ + and account.isreportuser and account.isreportenter and account.report_location_flag: + app_json['isreportuser'] = int(account.isreportuser) + app_json['isreportenter'] = int(account.isreportenter) + wechat.agent.set(agent_id=app_json['agentid'], name=app_json['name'], + description=app_json['description'], + redirect_domain=app_json['redirect_domain'], + is_report_user=app_json['isreportuser'], + is_report_enter=app_json['isreportenter'], + report_location_flag=app_json['report_location_flag']) + elif account.type == "2": # 主页型应用 + if account.name and account.agentid \ + and account.report_location_flag and account.home_url: + app_json['home_url'] = account.home_url + wechat.agent.set(agent_id=app_json['agentid'], name=app_json['name'], + description=app_json['description'], + redirect_domain=app_json['redirect_domain'], + is_report_user=app_json['isreportuser'], + is_report_enter=app_json['isreportenter'], + report_location_flag=app_json['report_location_flag']) + + except Exception as e: + _logger.warning(u'更新app失败,原因:%s', str(e)) + raise Warning(u'更新app失败,原因:%s', str(e)) + else: + raise exceptions.Warning(u"初始化企业号失败") + + def update_app_menu(self): + """ + 同步菜单至app + :return: + """ + wechat = self.env['we.config'].sudo().get_wechat(agentID=self.agentid) + menus = self.env['we.app.menu'].sudo().search([("agentid", "=", self.name)]) + wechat.menu.delete(agent_id=self.agentid) + menu_json = {'button': []} + button = [] + if wechat and menus: + for menu in menus: + menu_data = { + 'name': menu['name'] + } + if not menu['partner_menu_id']: + sub_menus = request.env['we.app.menu'].sudo().search( + [("agentid", "=", self.name), ("partner_menu_id", "=", menu['name'])]) + if sub_menus and (len(sub_menus) > 0) and (len(sub_menus) < 6): + sub_menu_list = [] + for sub_menu in sub_menus: + sub_menu_data = { + 'name': sub_menu['name'] + } + if menu['type'] == 'xml' or menu['type'] == 'sub_button': + sub_menu_data['type'] = sub_menu['type'] + sub_menu_data['url'] = sub_menu['url'] + else: + sub_menu_data['type'] = sub_menu['type'] + sub_menu_data['key'] = sub_menu['key'] + sub_menu_list.append(sub_menu_data) + menu_data['sub_button'] = sub_menu_list + else: + if menu['type'] == 'xml' or menu['type'] == 'sub_button': + menu_data['type'] = menu['type'] + menu_data['url'] = menu['url'] + else: + menu_data['type'] = menu['type'] + menu_data['key'] = menu['key'] + button.append(menu_data) + menu_json['button'] = button + wechat.menu.update(agent_id=self.agentid, menu_data=menu_json) + else: + raise exceptions.Warning(u"初始化企业号失败或该应用无菜单") + + +class WechatEnterpriseAppMenu(models.Model): + _name = 'we.app.menu' + _description = 'Wechat Enterprise App Menu Manage' + + agentid = fields.Many2one('we.app', u'企业应用', required=True) + partner_menu_id = fields.Many2one('we.app.menu', u'上级菜单') + type = fields.Selection( + [('sub_button', u'跳转至子菜单'), ('click', u'点击推事件'), ('xml', u'跳转URL'), ('scancode_push', u'扫码推事件'), + ('scancode_waitmsg', u'扫码推事件且弹出“消息接收中”提示框'), + ('pic_sysphoto', u'弹出系统拍照发图'), ('pic_photo_or_album', u'弹出拍照或者相册发图'), ('pic_weixin', u'弹出微信相册发图器'), + ('location_select', u'弹出地理位置选择器')], u'按钮的类型', required=True, default='xml') + name = fields.Char(u'菜单标题', required=True) + key = fields.Char(u'菜单KEY值') + url = fields.Char(u'网页链接') + + @api.constrains('partner_menu_id', 'name') + def _check_menu_name_length(self): + if self.name and self.partner_menu_id and len(self.name) > 7: + raise Warning(u'二级菜单显示名称不能超过14个字符或7个汉字.') + elif self.name and not self.partner_menu_id and len(self.name) > 4: + raise Warning(u'一级菜单显示名称不能超过8个字符或4个汉字.') + else: + return True + + @api.constrains('agentid') + def check_menu_number(self): + """ + 取得一个app的一级菜单量 + :return: + """ + menus_ids = self.sudo().search([('agentid', '=', self.agentid['name']), ('partner_menu_id', '=', False)]) + + if menus_ids and len(menus_ids) > 3: + raise Warning(u'公众号的一级菜单数据不能超过3个.') + + return True + + @api.constrains('partner_menu_id') + def check_submenu_number(self): + """ + 取得一个一级菜单的子菜单数量 + :return: + """ + sub_menus_ids = self.sudo().search( + [('partner_menu_id', '=', self.partner_menu_id['name']), ('partner_menu_id', '!=', False)]) + + if sub_menus_ids and len(sub_menus_ids) > 5: + raise Warning(u'一级菜单的二级子菜单数据不能超过5个.') + + return True diff --git a/sg_wechat_enterprise/models/we_conf.py b/sg_wechat_enterprise/models/we_conf.py new file mode 100644 index 00000000..403e900c --- /dev/null +++ b/sg_wechat_enterprise/models/we_conf.py @@ -0,0 +1,62 @@ +import logging +import requests +import time +import base64 +from odoo.http import request +from odoo import api, models, exceptions, fields, _ +from wechatpy.enterprise import WeChatClient +from .client import JkmWeChatClient +from json import * + +_logger = logging.getLogger(__name__) + + +class WechatEnterpriseConfigration(models.Model): + _name = 'we.config' + + name = fields.Char(u'名称', required=True, help=u'取个好名字吧!') + corp_id = fields.Char(u'企业ID', required=True, help=u'企业ID,必填项') + corp_secret = fields.Char(u'通讯录同步Secret', required=True, help=u'通讯录同步Secret,必填项') + odoo_app_id = fields.Many2one('we.app', u'Odoo应用', help=u'在企业微信工作台配置的与Odoo进行连接的应用') + company_id = fields.Many2one('res.company', string=u'所属公司') + + _sql_constraints = [ + ('code_complete_name_uniq', 'unique (company_id)', '一个所属公司只能定义一个企业微信!') + ] + + def get_wechat(self, agent_id=None, company_id=1): + """ + 取得wechat app的实例 + :param agent_id: conf or None (是企业号对象还是应用对象) + :return: + """ + enterprise = self.env['we.config'].sudo().search([('company_id', '=', company_id)], limit=1) + if agent_id: + enterprise_app = self.env['we.app'].sudo().search([('agentid', '=', agent_id)]) + return WeChatClient(corp_id=enterprise.corp_id, secret=enterprise_app.secret) + return WeChatClient(corp_id=enterprise.corp_id, secret=enterprise.corp_secret) + + def get_odoo_wechat(self, company_id=1): + """ + 取得Odoo wechat app的实例 + :param agent_id: conf or None (是企业号对象还是应用对象) + :return: + """ + enterprise = self.env['we.config'].sudo().search([('company_id', '=', company_id)], limit=1) + if enterprise.odoo_app_id: + return (JkmWeChatClient(corp_id=enterprise.corp_id, secret=enterprise.odoo_app_id.secret), + enterprise.odoo_app_id.agentid) + else: + raise exceptions.Warning(u'Odoo应用未配置. ') + + def get_wechat_app(self, app_code=None, company_id=1): + """ + 取得wechat app的实例 + :param app_code: 应用代码 + :return: + """ + enterprise = self.env['we.config'].sudo().search([('company_id', '=', company_id)], limit=1) + if app_code: + enterprise_app = self.env['we.app'].sudo().search([('code', '=', app_code)]) + return WeChatClient(corp_id=enterprise.corp_id, secret=enterprise_app.secret) + return WeChatClient(corp_id=enterprise.corp_id, secret=enterprise.corp_secret) \ No newline at end of file diff --git a/sg_wechat_enterprise/models/we_receive_message.py b/sg_wechat_enterprise/models/we_receive_message.py new file mode 100644 index 00000000..cbeaedcc --- /dev/null +++ b/sg_wechat_enterprise/models/we_receive_message.py @@ -0,0 +1,143 @@ +from odoo import api, models, fields +import logging +from odoo.http import request +import datetime + +_logger = logging.getLogger(__name__) + + +class WechatEnterpriseReceiveMessage(models.Model): + _name = 'we.receive.message' + _description = 'Wechat Enterprise Receive Message Manage' + + name = fields.Char(required=True, index=True) + ToUserName = fields.Char(u'企业号CorpID') + FromUserName = fields.Char(u'成员UserID') + CreateTime = fields.Char(u'消息创建时间') + MsgId = fields.Char(u'消息id') + AgentID = fields.Many2one('we.app', u'企业应用') + MsgType = fields.Selection([('text', u'文本'), + ('voice', u'语音'), + ('image', u'图片'), + ('video', u'视频'), + ('shortvideo', u'短视频'), + ('location', u'位置'), + ('link', u'链接'), + ('subscribe', u'关注'), + ('unsubscribe', u'取消关注'), + ('xml', u'自定义菜单链接跳转'), + ('click', u'自定义菜单点击'), + ('scan', u'扫描二维码'), + ('scancode_waitmsg', u'扫描二维码并等待'), + ('event', u'取消关注')], + string=u'消息类型', required=True, default='text') + Content = fields.Text(u'文本消息内容') + state = fields.Selection([('1', u'未处理'), ('2', u'已处理'), ('3', u'处理失败')], u'状态', default='1') + PicUrl = fields.Char(u'图片链接') + MediaId = fields.Char(u'图片媒体文件id') + Format = fields.Char(u'语音格式') + ThumbMediaId = fields.Char(u'视频消息缩略图的媒体id') + Location_X = fields.Char(u'地理位置纬度') + Location_Y = fields.Char(u'地理位置经度') + Scale = fields.Char(u'地图缩放大小') + Label = fields.Char(u'地理位置信息') + Title = fields.Char(u'标题') + Description = fields.Char(u'描述') + Cover_PicUrl = fields.Char(u'封面缩略图的url') + + @api.depends('MsgType', 'MsgId') + def name_get(self): + result = [] + for receive in self: + name = receive.MsgType + '_' + receive.MsgId + result.append((receive.id, name)) + return result + + def add_message(self, data): + """ + 增加一条待处理的上传消息 + :param data: + :return: + """ + + app = request.env['we.app'].sudo().search([("agentid", "=", data["AgentID"])]) + receive_message_data = { + 'AgentID': app.id, + 'MsgType': data["MsgType"], + 'FromUserName': data["FromUserName"], + 'ToUserName': data["ToUserName"] + } + current_time = datetime.datetime.now() + real_time = current_time + datetime.timedelta(hours=8) + receive_message_data["CreateTime"] = real_time + receive_message_data["name"] = data["MsgType"] + data["MsgId"] + + if data["MsgType"] == "text": + receive_message_data["MsgId"] = data["MsgId"] + receive_message_data["Content"] = data["Content"] + if data["MsgType"] == "image": + receive_message_data["MsgId"] = data["MsgId"] + receive_message_data["PicUrl"] = data["PicUrl"] + receive_message_data["MediaId"] = data["MediaId"] + if data["MsgType"] == "voice": + receive_message_data["MsgId"] = data["MsgId"] + receive_message_data["MediaId"] = data["MediaId"] + receive_message_data["Format"] = data["Format"] + if data["MsgType"] == "video" or data["MsgType"] == "shortvideo": + receive_message_data["MsgId"] = data["MsgId"] + receive_message_data["MediaId"] = data["MediaId"] + receive_message_data["ThumbMediaId"] = data["ThumbMediaId"] + if data["MsgType"] == "location": + receive_message_data["MsgId"] = data["MsgId"] + receive_message_data["Location_X"] = data["Location_X"] + receive_message_data["Location_Y"] = data["Location_Y"] + receive_message_data["Scale"] = data["Scale"] + receive_message_data["Label"] = data["Label"] + if data["MsgType"] == "link": + receive_message_data["MsgId"] = data["MsgId"] + receive_message_data["Title"] = data["Title"] + receive_message_data["Description"] = data["Description"] + receive_message_data["Cover_PicUrl"] = data["PicUrl"] + if data["MsgType"] == "event": + if data["Event"] == "subscribe": + receive_message_data["MsgType"] = "subscribe" + if data["Event"] == "unsubscribe": + receive_message_data["MsgType"] = "unsubscribe" + + return super(WechatEnterpriseReceiveMessage, self).create(receive_message_data) + + def process_message(self, data): + """ + 处理未处理和失败的消息 + :param data: + :return: + """ + messages = self.sudo().add_message(data) + for message in messages: + if message: + if message.state == '2': + break + if data["MsgType"] == "text": + process = self.env['we.receive.message.process'].get_message_process(data["MsgType"], + data["Content"], + data["AgentID"]) + else: + process = self.env['we.receive.message.process'].get_message_process(data["MsgType"], + " ", + data["AgentID"]) + try: + if process: + if data["MsgType"] == "voice" or data["MsgType"] == "image" or data["MsgType"] == "video" or \ + data["MsgType"] == "shortvideo": + process.sudo().exec_class_mothed(data["FromUserName"], data["AgentID"], data["MediaId"]) + else: + process.sudo().exec_class_mothed(data["FromUserName"], data["AgentID"]) + else: + return self.env['we.send.message'].sudo().send_text_message(data["FromUserName"], + data["AgentID"], + content=u'感谢您的关注!') + + message.sudo().write({'state': '1'}) + except Exception as e: + message.sudo().write({'state': u'处理失败'}) + raise Warning(u'处理失败, 原因:%s', str(e)) diff --git a/sg_wechat_enterprise/models/we_receive_message_process.py b/sg_wechat_enterprise/models/we_receive_message_process.py new file mode 100644 index 00000000..02acb682 --- /dev/null +++ b/sg_wechat_enterprise/models/we_receive_message_process.py @@ -0,0 +1,109 @@ +from odoo import models, fields +import logging + +_logger = logging.getLogger(__name__) + + +class WechatEnterpriseReceiveMessageProcess(models.Model): + _name = 'we.receive.message.process' + _description = 'Wechat Enterprise Process Receive Process Message' + + name = fields.Char(u'名称', help=u'取个好名称方便管理,比如优惠信息索取', required=True) + message_type = fields.Selection([('text', u'文本'), + ('voice', u'语音'), + ('image', u'图片'), + ('video', u'视频'), + ('shortvideo', u'短视频'), + ('location', u'位置'), + ('link', u'链接'), + ('subscribe', u'关注'), + ('unsubscribe', u'取消关注'), + ('xml', u'自定义菜单链接跳转'), + ('click', u'自定义菜单点击'), + ('scan', u'扫描二维码'), + ('scancode_waitmsg', u'扫描二维码并等待'), + ('unsubscribe', u'取消关注')], + string=u'消息类型', required=True) + message_key = fields.Char(u'关键字', required=True) + class_name = fields.Char(u'负责处理的类', help='此处填写进行处理的类的名称),例如:topro_service_base.test', required=True, default="类的名称") + method_name = fields.Char(u'负责处理的方法', help='此处填写进入处理的方法名,方法必须包括参数是message和account_id(微信公众号的id),这是一个dict', + required=True, default="方法名") + agentID = fields.Many2one('we.app', u'企业应用', required=True) + note = fields.Text(u'备注') + + def get_message_process(self, message_type, key_word=False, agent_id=False): + """ + 取得消息处理的设置 + :param message_type: + :param key_word: + :param agent_id: + :return: + """ + process = False + if message_type: + process = self.sudo().search( + [('message_type', '=', message_type), ('message_key', '=', key_word), ('agentID', '=', agent_id)], + limit=1) + if not process and message_type and key_word: + process = self.sudo().search([('message_type', '=', message_type), ('message_key', '=', key_word)], limit=1) + if not process and message_type: + process = self.sudo().search([('message_type', '=', message_type)], limit=1) + return process + + def exec_by_message_type(self, message_type, message_key, agent_id): + """ + 根据消息的类型动态调用类进行执行 + :param message_type: + :param message_key: + :param agent_id: + :return: + """ + + # 取得对象, + if message_type and message_key: + process = self.get_message_process(message_type, message_key, agent_id) + process.sudo().exec_class_mothed(message_key, agent_id) + + def exec_class_mothed(self, from_user_name, agent_id, media_id=None): + """ + 执行类的方法 + :param from_user_name: + :param agent_id: + :param media_id: + :return: + """ + + _logger.debug('exec_class_mothed') + object_function = getattr(self.env[self.class_name], self.method_name) + ret = object_function(from_user_name, agent_id, media_id) + + return ret + + def hello(self, from_user_name, agent_id): + """ + demo方法,模拟动态处理客户的需求 + :param from_user_name: + :param agent_id: + :return: + """ + try: + self.env['we.send.message'].sudo(). \ + send_news_message(from_user_name, agent_id, u'测试图文信息', u'测试图文信息的描述', + 'http://www.baidu.com', + 'http://www.kia-hnsyt.com.cn/uploads/allimg/141204/1-1412041624240-L.jpg') + except Exception as e: + _logger.warning(u'发送微信文本失败原因:%s', str(e)) + raise Warning(str(e)) + + def send_img(self, from_user_name, agent_id): + """ + demo方法,模拟动态处理客户的需求 + :param from_user_name: + :param agent_id: + :return: + """ + try: + self.env['we.send.message'].sudo().send_text_message(from_user_name, agent_id, u'即将为您发送一条消息') + except Exception as e: + _logger.warning(u'发送微信文本失败原因:%s', str(e)) + raise Warning(str(e)) diff --git a/sg_wechat_enterprise/models/we_send_message.py b/sg_wechat_enterprise/models/we_send_message.py new file mode 100644 index 00000000..63a06042 --- /dev/null +++ b/sg_wechat_enterprise/models/we_send_message.py @@ -0,0 +1,108 @@ +from odoo import api, models, exceptions, fields +import logging + +_logger = logging.getLogger(__name__) + + +class WechatEnterpriseSendMessage(models.Model): + _name = 'we.send.message' + _description = 'Wechat Enterprise Send Message Manage' + + touser = fields.Many2many('res.users', 'send_message_to_users_ref', + 'wechat_contacts_id', 'touser', u'成员列表') + msgtype = fields.Selection([('text', u'文字消息'), ('image', u'图片消息'), ('voice', u'语音消息'), ('video', u'视频消息'), + ('file', u'文件消息'), ('news', u'图文消息'), ('mpnews', u'微信后台图文消息')], u'消息类型', + required=True, default='text') + agentid = fields.Many2one('we.app', u'发送消息的企业应用', required=True) + content = fields.Char(u'消息内容') + media_id = fields.Char(u'媒体文件') + title = fields.Char(u'标题') + description = fields.Text(u'描述') + articles = fields.Char(u'图文消息') + url = fields.Char(u'点击后跳转的链接') + picurl = fields.Char(u'图文消息的图片链接') + thumb_media_id = fields.Char(u'图文消息缩略图') + author = fields.Char(u'图文消息的作者') + content_source_url = fields.Char(u'图文消息点击“阅读原文”之后的页面链接') + news_content = fields.Char(u'图文消息的内容,支持html标签') + digest = fields.Char(u'图文消息的描述') + show_cover_pic = fields.Selection([('0', u'否'), ('1', u'是')], u'是否显示封面', default='0') + safe = fields.Selection([('0', u'否'), ('1', u'是')], u'是否是保密消息', required=True, default='0') + + def send_message(self): + """ + 发送消息给关注企业号的用户 + :return: + """ + users = "" + i = 0 + if self.touser and len(self.touser) > 0: + for data in self.touser: + i = i + 1 + if i == len(self.touser): + if data['we_employee_id']: + users = users + data['we_employee_id'] + else: + if data['we_employee_id']: + users = users + data['we_employee_id'] + "|" + if users == "": + users = '@all' + partys = "" + + if self.msgtype == "news": + self.send_news_message(users, self.agentid['agentid'], self.title, self.description, self.url, self.picurl) + elif self.msgtype == "text": + self.send_text_message(users, self.agentid['agentid'], self.content, partys) + + def send_text_message(self, userid, agentid, content, partyid=None): + """ + 发送文本消息给关注企业号的用户 + :param userid: + :param agentid: + :param content: + :param partyid: + :return: + """ + try: + wechat = self.env['we.config'].sudo().get_wechat(agent_id=agentid) + if wechat: + data = { + 'safe': "0", + 'msgtype': 'text', + 'agentid': agentid, + 'touser': userid, + 'toparty': partyid, + 'content': content + } + wechat.message.send_text(agent_id=data['agentid'], user_ids=data['touser'], content=data['content'], + party_ids=data['toparty'], safe=data['safe']) + else: + raise exceptions.Warning(u"初始化企业号失败") + except Exception as e: + logging.error('send_text_message:%s' % e) + + # 发送图文消息给关注企业号的用户 + def send_news_message(self, userid, agentid, title=None, description=None, url=None, picurl=None): + """ + 发送图文消息给关注企业号的用户 + :param userid: + :param agentid: + :param title: + :param description: + :param url: + :param picurl: + :return: + """ + wechat = self.env['we.config'].sudo().get_wechat(agent_id=agentid) + if wechat: + articles = [ + { + 'url': url, + 'image': picurl, + 'description': description, + 'title': title + } + ] + wechat.message.send_articles(agent_id=agentid, user_ids=userid, articles=articles) + else: + raise exceptions.Warning(u"初始化企业号失败") diff --git a/sg_wechat_enterprise/models/we_tools.py b/sg_wechat_enterprise/models/we_tools.py new file mode 100644 index 00000000..3dd8fc45 --- /dev/null +++ b/sg_wechat_enterprise/models/we_tools.py @@ -0,0 +1,89 @@ +from odoo import api, models, exceptions +from odoo.http import request +import logging +import hashlib +import base64 +import time +import requests + +_logger = logging.getLogger(__name__) + + +class WechatEnterpriseTools(models.Model): + """ + 微信企业号工具类 + """ + _name = 'we.tools' + _description = '微信企业号工具类' + + def get_media(self, media_id): + """ + 通过media_id 获取媒体文件 + :param media_id: media id + :return: + """ + wechat = self.env['we.config'].sudo().get_wechat() + try: + media = wechat.media.download(media_id=media_id) + return { + 'errcode': 0, + 'errmsg': 'ok', + 'media': media.content + } + except Exception as ex: + _logger.info(u'get media fail, message: {str(ex)}.') + return { + 'errcode': 30001, + 'errmsg': str(ex) + } + + def get_jsapi_ticket(self): + """ + 获取jsapi_ticket + :return: + """ + if request.session.get('ticket') and request.session.get('ticket_time') \ + and int(time.time()) - request.session['ticket_time'] <= 7000: + return { + 'errcode': 0, + 'errmsg': 'ok', + 'ticket': request.session['ticket'] + } + wechat = self.env['we.config'].sudo().get_wechat() + get_token = wechat.fetch_access_token() + if get_token['errcode'] == 0 and get_token['errmsg'] == 'ok': + access_token = get_token['access_token'] + url = u'https://qyapi.weixin.qq.com/cgi-bin/get_jsapi_ticket?access_token={access_token}' + response = requests.get(url).json() + if response['errcode'] == 0 and response['errmsg'] == 'ok': + request.session['ticket'] = response['ticket'] + request.session['ticket_time'] = int(time.time()) + return response + return { + "errcode": 10002, + "errmsg": "get ticket fail." + } + + def check_message_signature(self, message_list, msg_signature): + """ + 校验消息的正确性 + :param message_list: 消息列表 (list: token, timestamp, nonce, echo_string) + :param msg_signature: 签名 + :return: true or false + """ + _logger.info(u'check message signature.') + message_list.sort() + message_str = "".join(message_list) + sha1_message = hashlib.sha1(str(message_str).encode('utf-8')).hexdigest() + if sha1_message == msg_signature: + return True + return False + + def decode_echo_str(self, echo_str): + """ + 解密echo string 得到明文内容 + :param echo_str: 加密字符串 + :return: message + """ + _logger.info(u'decode echo string.') + base64_str = base64.b64decode(echo_str) diff --git a/sg_wechat_enterprise/requirements.txt b/sg_wechat_enterprise/requirements.txt new file mode 100644 index 00000000..c4f4248f --- /dev/null +++ b/sg_wechat_enterprise/requirements.txt @@ -0,0 +1 @@ +wechatpy==1.8.6 diff --git a/sg_wechat_enterprise/security/ir.model.access.csv b/sg_wechat_enterprise/security/ir.model.access.csv new file mode 100644 index 00000000..4a21fc53 --- /dev/null +++ b/sg_wechat_enterprise/security/ir.model.access.csv @@ -0,0 +1,13 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink + +access_user_we_app,we_app,model_we_app,,1,1,1,1 + +access_user_we_config,we_config,model_we_config,,1,1,1,1 + +access_user_we_receive_message,we_receive_message,model_we_receive_message,,1,1,1,1 + +access_user_we_receive_message_process,we_receive_message_process,model_we_receive_message_process,,1,1,1,1 + +access_user_we_send_message,we_send_message,model_we_send_message,,1,1,1,1 + +access_user_we_app_menu,we_app_menu,model_we_app_menu,,1,1,1,1 diff --git a/sg_wechat_enterprise/static/css/loading.css b/sg_wechat_enterprise/static/css/loading.css new file mode 100644 index 00000000..dd6243a7 --- /dev/null +++ b/sg_wechat_enterprise/static/css/loading.css @@ -0,0 +1,24 @@ +@-webkit-keyframes loadingCircle { + to { + transform:rotate(1turn) + } +} +@keyframes loadingCircle { + to { + transform:rotate(1turn) + } +} +.load-box { + position:fixed; + top:calc(50% - 11px); + left:calc(50% - 111px); + display:flex; + align-items:center; +} +.load-icon { + animation:loadingCircle 1s linear infinite; +} +.load-txt { + font-size:16px; + margin-left:10px; +} \ No newline at end of file diff --git a/sg_wechat_enterprise/static/description/icon.png b/sg_wechat_enterprise/static/description/icon.png new file mode 100644 index 00000000..c83caaea Binary files /dev/null and b/sg_wechat_enterprise/static/description/icon.png differ diff --git a/sg_wechat_enterprise/static/description/qyh.png b/sg_wechat_enterprise/static/description/qyh.png new file mode 100644 index 00000000..9c4e4f24 Binary files /dev/null and b/sg_wechat_enterprise/static/description/qyh.png differ diff --git a/sg_wechat_enterprise/static/js/url_transfers.js b/sg_wechat_enterprise/static/js/url_transfers.js new file mode 100644 index 00000000..d3b61ee1 --- /dev/null +++ b/sg_wechat_enterprise/static/js/url_transfers.js @@ -0,0 +1,26 @@ +/** + * Created by jiangxiang on 2016/3/14. + */ + +//��ȡ���Ӵ�������openID���� +function getUrlParam(url, name) { + var pattern = new RegExp("[?&]" + name + "\=([^&]+)", "g"); + var matcher = pattern.exec(url); + var items = null; + if (matcher != null) { + try { + items = decodeURIComponent(decodeURIComponent(matcher[1])); + } catch (e) { + try { + items = decodeURIComponent(matcher[1]); + } catch (e) { + items = matcher[1]; + } + } + } + items = items.replace(/^\s*/, ""); + return items; +} + +var url = document.getElementById("url").innerText; +window.location.href = url; \ No newline at end of file diff --git a/sg_wechat_enterprise/tests/__init__.py b/sg_wechat_enterprise/tests/__init__.py new file mode 100644 index 00000000..40a96afc --- /dev/null +++ b/sg_wechat_enterprise/tests/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/sg_wechat_enterprise/views/app_view.xml b/sg_wechat_enterprise/views/app_view.xml new file mode 100644 index 00000000..8243900b --- /dev/null +++ b/sg_wechat_enterprise/views/app_view.xml @@ -0,0 +1,84 @@ + + + + + we.app.form + we.app + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + we.app.tree + we.app + + + + + + + + + + + + + + we.app.search + we.app + primary + + + + + + + + + + 应用配置 + ir.actions.act_window + we.app + tree,form + form + + + + + + diff --git a/sg_wechat_enterprise/views/mail_view.xml b/sg_wechat_enterprise/views/mail_view.xml new file mode 100644 index 00000000..5aee337b --- /dev/null +++ b/sg_wechat_enterprise/views/mail_view.xml @@ -0,0 +1,23 @@ + + + + + mail.message.form.dingtalk + mail.message + + + + + + + + + +
+
+
+
+
+
+
\ No newline at end of file diff --git a/sg_wechat_enterprise/views/menu_view.xml b/sg_wechat_enterprise/views/menu_view.xml new file mode 100644 index 00000000..781102cc --- /dev/null +++ b/sg_wechat_enterprise/views/menu_view.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/sg_wechat_enterprise/views/res_users_view.xml b/sg_wechat_enterprise/views/res_users_view.xml new file mode 100644 index 00000000..c65d8e46 --- /dev/null +++ b/sg_wechat_enterprise/views/res_users_view.xml @@ -0,0 +1,20 @@ + + + + + res.users.account.form + res.users + + + + + + + + + + + + + + diff --git a/sg_wechat_enterprise/views/we_app_view.xml b/sg_wechat_enterprise/views/we_app_view.xml new file mode 100644 index 00000000..1d85dd14 --- /dev/null +++ b/sg_wechat_enterprise/views/we_app_view.xml @@ -0,0 +1,149 @@ + + + + + we.app.form + we.app + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + we.app.tree + we.app + + + + + + + + + + + + + + we.app.search + we.app + primary + + + + + + + + + + 应用配置 + ir.actions.act_window + we.app + tree,form + form + + + + + we.app.menu.form + we.app.menu + +
+ + + + + + + + + + + + + + + + +
+
+
+ + + + we.app.menu.tree + we.app.menu + + + + + + + + + + + + + + + we.app.menu.search + we.app.menu + primary + + + + + + + + + + 应用菜单 + ir.actions.act_window + we.app.menu + tree,form + form + + + +
+
+ diff --git a/sg_wechat_enterprise/views/we_config_view.xml b/sg_wechat_enterprise/views/we_config_view.xml new file mode 100644 index 00000000..85a970a7 --- /dev/null +++ b/sg_wechat_enterprise/views/we_config_view.xml @@ -0,0 +1,48 @@ + + + + + we.config.form + we.config + +
+ + + + + + + + + + + +
+
+
+ + + + we.config.tree + we.config + + + + + + + + + + + + 企业微信 + ir.actions.act_window + we.config + tree,form + form + + +
+
+ diff --git a/sg_wechat_enterprise/views/we_menu.xml b/sg_wechat_enterprise/views/we_menu.xml new file mode 100644 index 00000000..d65c669b --- /dev/null +++ b/sg_wechat_enterprise/views/we_menu.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sg_wechat_enterprise/views/we_message_process_view.xml b/sg_wechat_enterprise/views/we_message_process_view.xml new file mode 100644 index 00000000..26fef5d0 --- /dev/null +++ b/sg_wechat_enterprise/views/we_message_process_view.xml @@ -0,0 +1,70 @@ + + + + + we.receive.message.process.form + we.receive.message.process + +
+ + + + + + + + + + + + + + + + + +
+
+
+ + + + we.receive.message.process.tree + we.receive.message.process + + + + + + + + + + + + + + + we.receive.message.process.search + we.receive.message.process + primary + + + + + + + + + + 接收消息处理 + ir.actions.act_window + we.receive.message.process + tree,form + form + + + +
+
+ diff --git a/sg_wechat_enterprise/views/we_receive_message_view.xml b/sg_wechat_enterprise/views/we_receive_message_view.xml new file mode 100644 index 00000000..26db7b3e --- /dev/null +++ b/sg_wechat_enterprise/views/we_receive_message_view.xml @@ -0,0 +1,98 @@ + + + + + we.receive.message.form + we.receive.message + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + we.receive.message.tree + we.receive.message + + + + + + + + + + + + + + + + + we.receive.message.search + we.receive.message + primary + + + + + + + + + + 接收消息 + ir.actions.act_window + we.receive.message + tree,form + form + + + +
+
+ diff --git a/sg_wechat_enterprise/views/we_send_message_view.xml b/sg_wechat_enterprise/views/we_send_message_view.xml new file mode 100644 index 00000000..e18ff9f7 --- /dev/null +++ b/sg_wechat_enterprise/views/we_send_message_view.xml @@ -0,0 +1,105 @@ + + + + + we.send.message.form + we.send.message + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + we.send.message.tree + we.send.message + + + + + + + + + + + + + we.send.message.search + we.send.message + primary + + + + + + + + + + + + 发送消息 + ir.actions.act_window + we.send.message + tree,form + form + + + +
+
+ diff --git a/sg_wechat_enterprise/views/we_templates.xml b/sg_wechat_enterprise/views/we_templates.xml new file mode 100644 index 00000000..6694833a --- /dev/null +++ b/sg_wechat_enterprise/views/we_templates.xml @@ -0,0 +1,45 @@ + + + + + + + + + + \ No newline at end of file diff --git a/sg_wechat_enterprise/we_api/__init__.py b/sg_wechat_enterprise/we_api/__init__.py new file mode 100644 index 00000000..e4a50359 --- /dev/null +++ b/sg_wechat_enterprise/we_api/__init__.py @@ -0,0 +1,33 @@ +from __future__ import absolute_import, unicode_literals +import logging +try: + from pkgutil import extend_path + __path__ = extend_path(__path__, __name__) +except ImportError: + from pkg_resources import declare_namespace + declare_namespace(__name__) + +from wechatpy.parser import parse_message # NOQA +from wechatpy.replies import create_reply # NOQA +from wechatpy.client import WeChatClient # NOQA +from wechatpy.exceptions import WeChatException # NOQA +from wechatpy.exceptions import WeChatClientException # NOQA +from wechatpy.oauth import WeChatOAuth # NOQA +from wechatpy.exceptions import WeChatOAuthException # NOQA +from wechatpy.pay import WeChatPay # NOQA +from wechatpy.exceptions import WeChatPayException # NOQA +from wechatpy.component import WeChatComponent # NOQA + + +__version__ = '1.3.1' +__author__ = 'messense' + +# Set default logging handler to avoid "No handler found" warnings. +try: # Python 2.7+ + from logging import NullHandler +except ImportError: + class NullHandler(logging.Handler): + def emit(self, record): + pass + +logging.getLogger(__name__).addHandler(NullHandler()) diff --git a/sg_wechat_enterprise/we_api/_compat.py b/sg_wechat_enterprise/we_api/_compat.py new file mode 100644 index 00000000..d2889243 --- /dev/null +++ b/sg_wechat_enterprise/we_api/_compat.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +""" + wechatpy._compat + ~~~~~~~~~~~~~~~~~ + + This module makes it easy for wechatpy to run on both Python 2 and 3. + + :copyright: (c) 2014 by messense. + :license: MIT, see LICENSE for more details. +""" +from __future__ import absolute_import, unicode_literals +import sys +import six +import warnings + +warnings.warn("Module `wechatpy._compat` is deprecated, will be removed in 2.0" + "use `wechatpy.utils` instead", + DeprecationWarning, stacklevel=2) + +from wechatpy.utils import get_querystring +from wechatpy.utils import json diff --git a/sg_wechat_enterprise/we_api/client/__init__.py b/sg_wechat_enterprise/we_api/client/__init__.py new file mode 100644 index 00000000..02c6c525 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/__init__.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +try: + from pkgutil import extend_path + __path__ = extend_path(__path__, __name__) +except ImportError: + from pkg_resources import declare_namespace + declare_namespace(__name__) + +import time + +from wechatpy.client.base import BaseWeChatClient +from wechatpy.client import api + + +class WeChatClient(BaseWeChatClient): + + """ + 微信 API 操作类 + 通过这个类可以操作微信 API,发送主动消息、群发消息和创建自定义菜单等。 + """ + + API_BASE_URL = 'https://api.weixin.qq.com/cgi-bin/' + + menu = api.WeChatMenu() + user = api.WeChatUser() + group = api.WeChatGroup() + media = api.WeChatMedia() + card = api.WeChatCard() + qrcode = api.WeChatQRCode() + message = api.WeChatMessage() + misc = api.WeChatMisc() + merchant = api.WeChatMerchant() + customservice = api.WeChatCustomService() + datacube = api.WeChatDataCube() + jsapi = api.WeChatJSAPI() + material = api.WeChatMaterial() + semantic = api.WeChatSemantic() + shakearound = api.WeChatShakeAround() + device = api.WeChatDevice() + template = api.WeChatTemplate() + poi = api.WeChatPoi() + wifi = api.WeChatWiFi() + scan = api.WeChatScan() + + def __init__(self, appid, secret, access_token=None, + session=None, timeout=None, auto_retry=True): + super(WeChatClient, self).__init__( + appid, access_token, session, timeout, auto_retry + ) + self.appid = appid + self.secret = secret + + def fetch_access_token(self): + """ + 获取 access token + 详情请参考 http://mp.weixin.qq.com/wiki/index.php?title=通用接口文档 + + :return: 返回的 JSON 数据包 + """ + return self._fetch_access_token( + url='https://api.weixin.qq.com/cgi-bin/token', + params={ + 'grant_type': 'client_credential', + 'appid': self.appid, + 'secret': self.secret + } + ) + + +class WeChatComponentClient(WeChatClient): + + """ + 开放平台代公众号调用客户端 + """ + + def __init__(self, appid, component, access_token=None, + refresh_token=None, session=None, timeout=None): + # 未用到secret,所以这里没有 + super(WeChatComponentClient, self).__init__( + appid, '', access_token, session, timeout + ) + self.appid = appid + self.component = component + # 如果公众号是刚授权,外部还没有缓存access_token和refresh_token + # 可以传入这两个值,session 会缓存起来。 + # 如果外部已经缓存,这里只需要传入 appid,component和session即可 + if access_token: + self.session.set(self.access_token_key, access_token, 7200) + if refresh_token: + self.session.set(self.refresh_token_key, refresh_token, 7200) + + @property + def access_token_key(self): + return '{0}_access_token'.format(self.appid) + + @property + def refresh_token_key(self): + return '{0}_refresh_token'.format(self.appid) + + @property + def access_token(self): + access_token = self.session.get(self.access_token_key) + if not access_token: + self.fetch_access_token() + access_token = self.session.get(self.access_token_key) + return access_token + + @property + def refresh_token(self): + return self.session.get(self.refresh_token_key) + + def fetch_access_token(self): + """ + 获取 access token + 详情请参考 https://open.weixin.qq.com/cgi-bin/showdocument?action=dir_list\ + &t=resource/res_list&verify=1&id=open1419318587&token=&lang=zh_CN + + 这是内部刷新机制。请不要完全依赖! + 因为有可能在缓存期间没有对此公众号的操作,造成refresh_token失效。 + + :return: 返回的 JSON 数据包 + """ + expires_in = 7200 + result = self.component.refresh_authorizer_token( + self.appid, self.refresh_token) + if 'expires_in' in result: + expires_in = result['expires_in'] + self.session.set( + self.access_token_key, + result['authorizer_access_token'], + expires_in + ) + self.session.set( + self.refresh_token_key, + result['authorizer_refresh_token'], + expires_in + ) + self.expires_at = int(time.time()) + expires_in + return result diff --git a/sg_wechat_enterprise/we_api/client/api/__init__.py b/sg_wechat_enterprise/we_api/client/api/__init__.py new file mode 100644 index 00000000..30291577 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/__init__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +from wechatpy.client.api.menu import WeChatMenu # NOQA +from wechatpy.client.api.user import WeChatUser # NOQA +from wechatpy.client.api.card import WeChatCard # NOQA +from wechatpy.client.api.group import WeChatGroup # NOQA +from wechatpy.client.api.media import WeChatMedia # NOQA +from wechatpy.client.api.message import WeChatMessage # NOQA +from wechatpy.client.api.qrcode import WeChatQRCode # NOQA +from wechatpy.client.api.misc import WeChatMisc # NOQA +from wechatpy.client.api.merchant import WeChatMerchant # NOQA +from wechatpy.client.api.customservice import WeChatCustomService # NOQA +from wechatpy.client.api.datacube import WeChatDataCube # NOQA +from wechatpy.client.api.jsapi import WeChatJSAPI # NOQA +from wechatpy.client.api.material import WeChatMaterial # NOQA +from wechatpy.client.api.semantic import WeChatSemantic # NOQA +from wechatpy.client.api.shakearound import WeChatShakeAround # NOQA +from wechatpy.client.api.device import WeChatDevice # NOQA +from wechatpy.client.api.template import WeChatTemplate # NOQA +from wechatpy.client.api.poi import WeChatPoi # NOQA +from wechatpy.client.api.wifi import WeChatWiFi # NOQA +from wechatpy.client.api.scan import WeChatScan # NOQA diff --git a/sg_wechat_enterprise/we_api/client/api/base.py b/sg_wechat_enterprise/we_api/client/api/base.py new file mode 100644 index 00000000..790d9414 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/base.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + + +class BaseWeChatAPI(object): + + API_BASE_URL = '' + + """ WeChat API base class """ + def __init__(self, client=None): + self._client = client + + def _get(self, url, **kwargs): + if getattr(self, 'API_BASE_URL', None): + kwargs['api_base_url'] = self.API_BASE_URL + return self._client.get(url, **kwargs) + + def _post(self, url, **kwargs): + if getattr(self, 'API_BASE_URL', None): + kwargs['api_base_url'] = self.API_BASE_URL + return self._client.post(url, **kwargs) + + @property + def access_token(self): + return self._client.access_token + + @property + def session(self): + return self._client.session diff --git a/sg_wechat_enterprise/we_api/client/api/card.py b/sg_wechat_enterprise/we_api/client/api/card.py new file mode 100644 index 00000000..5f6ebe35 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/card.py @@ -0,0 +1,431 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatCard(BaseWeChatAPI): + + API_BASE_URL = 'https://api.weixin.qq.com/' + + def create(self, card_data): + """ + 创建卡券 + + :param card_data: 卡券信息 + :return: 创建的卡券 ID + """ + result = self._post( + 'card/create', + data=card_data, + result_processor=lambda x: x['card_id'] + ) + return result + + def batch_add_locations(self, location_data): + """ + 批量导入门店信息 + + :param location_data: 门店信息 + :return: 门店 ID 列表,插入失败的门店元素值为 -1 + """ + result = self._post( + 'card/location/batchadd', + data=location_data, + result_processor=lambda x: x['location_id_list'] + ) + return result + + def batch_get_locations(self, offset=0, count=0): + """ + 批量获取门店信息 + """ + return self._post( + 'card/location/batchget', + data={ + 'offset': offset, + 'count': count + } + ) + + def get_colors(self): + """ + 获得卡券的最新颜色列表,用于创建卡券 + :return: 颜色列表 + """ + result = self._get( + 'card/getcolors', + result_processor=lambda x: x['colors'] + ) + return result + + def create_qrcode(self, qrcode_data): + """ + 创建卡券二维码 + + :param qrcode_data: 二维码信息 + :return: 二维码 ticket,可使用 :func:show_qrcode 换取二维码文件 + """ + result = self._post( + 'card/qrcode/create', + data=qrcode_data, + result_processor=lambda x: x['ticket'] + ) + return result + + def create_landingpage(self, buffer_data): + """ + 创建货架 + """ + result = self._post( + 'card/landingpage/create', + data=buffer_data + ) + return result + + def get_html(self, card_id): + """ + 图文消息群发卡券 + """ + result = self._post( + 'card/mpnews/gethtml', + data={ + 'card_id': card_id + }, + result_processor=lambda x: x['content'] + ) + return result + + def consume_code(self, code, card_id=None): + """ + 消耗 code + """ + card_data = { + 'code': code + } + if card_id: + card_data['card_id'] = card_id + return self._post( + 'card/code/consume', + data=card_data + ) + + def decrypt_code(self, encrypt_code): + """ + 解码加密的 code + """ + result = self._post( + 'card/code/decrypt', + data={ + 'encrypt_code': encrypt_code + }, + result_processor=lambda x: x['code'] + ) + return result + + def delete(self, card_id): + """ + 删除卡券 + """ + return self._post( + 'card/delete', + data={ + 'card_id': card_id + } + ) + + def get_code(self, code, card_id=None, check_consume=True): + """ + 查询 code 信息 + """ + card_data = { + 'code': code + } + if card_id: + card_data['card_id'] = card_id + if not check_consume: + card_data['check_consume'] = check_consume + return self._post( + 'card/code/get', + data=card_data + ) + + def get_card_list(self, openid, card_id=None): + """ + 用于获取用户卡包里的,属于该appid下的卡券。 + """ + card_data = { + 'openid': openid + } + if card_id: + card_data['card_id'] = card_id + return self._post( + 'card/user/getcardlist', + data=card_data + ) + + def batch_get(self, offset=0, count=50, status_list=None): + """ + 批量查询卡券信息 + """ + card_data = { + 'offset': offset, + 'count': count + } + if status_list: + card_data['status_list'] = status_list + return self._post( + 'card/batchget', + data=card_data + ) + + def get(self, card_id): + """ + 查询卡券详情 + """ + result = self._post( + 'card/get', + data={ + 'card_id': card_id + }, + result_processor=lambda x: x['card'] + ) + return result + + def update_code(self, card_id, old_code, new_code): + """ + 更新卡券 code + """ + return self._post( + 'card/code/update', + data={ + 'card_id': card_id, + 'code': old_code, + 'new_code': new_code + } + ) + + def invalid_code(self, code, card_id=None): + """ + 设置卡券失效 + """ + card_data = { + 'code': code + } + if card_id: + card_data['card_id'] = card_id + return self._post( + 'card/code/unavailable', + data=card_data + ) + + def update(self, card_data): + """ + 更新卡券信息 + """ + return self._post( + 'card/update', + data=card_data + ) + + def set_paycell(self, card_id, is_open): + """ + 更新卡券信息 + """ + return self._post( + 'card/paycell/set', + data={ + 'card_id': card_id, + 'is_open': is_open + } + ) + + def set_test_whitelist(self, openids=None, usernames=None): + """ + 设置卡券测试用户白名单 + """ + openids = openids or [] + usernames = usernames or [] + return self._post( + 'card/testwhitelist/set', + data={ + 'openid': openids, + 'username': usernames + } + ) + + def activate_membercard(self, membership_number, code, init_bonus=0, + init_balance=0, card_id=None): + """ + 激活/绑定会员卡 + """ + card_data = { + 'membership_number': membership_number, + 'code': code, + 'init_bonus': init_bonus, + 'init_balance': init_balance + } + if card_id: + card_data['card_id'] = card_id + return self._post( + 'card/membercard/activate', + data=card_data + ) + + def update_membercard(self, code, add_bonus=0, record_bonus='', + add_balance=0, record_balance='', card_id=None): + """ + 会员卡交易更新信息 + """ + card_data = { + 'code': code, + 'add_bonus': add_bonus, + 'add_balance': add_balance, + 'record_bonus': record_bonus, + 'record_balance': record_balance + } + if card_id: + card_data['card_id'] = card_id + return self._post( + 'card/membercard/updateuser', + data=card_data + ) + + def update_movie_ticket(self, code, ticket_class, show_time, duration, + screening_room, seat_number, card_id=None): + """ + 更新电影票 + """ + ticket = { + 'code': code, + 'ticket_class': ticket_class, + 'show_time': show_time, + 'duration': duration, + 'screening_room': screening_room, + 'seat_number': seat_number + } + if card_id: + ticket['card_id'] = card_id + return self._post( + 'card/movieticket/updateuser', + data=ticket + ) + + def checkin_boardingpass(self, code, passenger_name, seat_class, + etkt_bnr, seat='', gate='', boarding_time=None, + is_cancel=False, qrcode_data=None, card_id=None): + """ + 飞机票接口 + """ + data = { + 'code': code, + 'passenger_name': passenger_name, + 'class': seat_class, + 'etkt_bnr': etkt_bnr, + 'seat': seat, + 'gate': gate, + 'is_cancel': is_cancel + } + if boarding_time: + data['boarding_time'] = boarding_time + if qrcode_data: + data['qrcode_data'] = qrcode_data + if card_id: + data['card_id'] = card_id + return self._post( + 'card/boardingpass/checkin', + data=data + ) + + def update_luckymoney_balance(self, code, balance, card_id=None): + """ + 更新红包余额 + """ + card_data = { + 'code': code, + 'balance': balance + } + if card_id: + card_data['card_id'] = card_id + return self._post( + 'card/luckymoney/updateuserbalance', + data=card_data + ) + + def get_redirect_url(self, url, encrypt_code, card_id): + """ + 获取卡券跳转外链 + """ + from wechatpy.utils import WeChatSigner + + code = self.decrypt_code(encrypt_code) + + signer = WeChatSigner() + signer.add_data(self.secret) + signer.add_data(code) + signer.add_data(card_id) + signature = signer.signature + + r = '{url}?encrypt_code={code}&card_id={card_id}&signature={signature}' + return r.format( + url=url, + code=encrypt_code, + card_id=card_id, + signature=signature + ) + + def deposit_code(self, card_id, codes): + """ + 导入code + """ + card_data = { + 'card_id': card_id, + 'code': codes + } + return self._post( + 'card/code/deposit', + data=card_data + ) + + def get_deposit_count(self, card_id): + """ + 查询导入code数目 + """ + card_data = { + 'card_id': card_id, + } + return self._post( + 'card/code/getdepositcount', + data=card_data + ) + + def check_code(self, card_id, codes): + """ + 核查code + """ + card_data = { + 'card_id': card_id, + 'code': codes + } + return self._post( + 'card/code/checkcode', + data=card_data + ) + + def modify_stock(self, card_id, n): + """ + 修改库存 + """ + if n == 0: + return + card_data = { + 'card_id': card_id, + } + if n > 0: + card_data['increase_stock_value'] = n + elif n < 0: + card_data['reduce_stock_value'] = -n + return self._post( + 'card/modifystock', + data=card_data + ) diff --git a/sg_wechat_enterprise/we_api/client/api/customservice.py b/sg_wechat_enterprise/we_api/client/api/customservice.py new file mode 100644 index 00000000..0a2db30a --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/customservice.py @@ -0,0 +1,242 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import hashlib +import time +import datetime + +from six.moves.urllib.parse import quote +from optionaldict import optionaldict +from wechatpy.utils import to_binary +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatCustomService(BaseWeChatAPI): + + def add_account(self, account, nickname, password): + """ + 添加客服账号 + 详情请参考 + http://mp.weixin.qq.com/wiki/1/70a29afed17f56d537c833f89be979c9.html + + :param account: 完整客服账号,格式为:账号前缀@公众号微信号 + :param nickname: 客服昵称,最长6个汉字或12个英文字符 + :param password: 客服账号登录密码 + :return: 返回的 JSON 数据包 + """ + password = to_binary(password) + password = hashlib.md5(password).hexdigest() + return self._post( + 'https://api.weixin.qq.com/customservice/kfaccount/add', + data={ + 'kf_account': account, + 'nickname': nickname, + 'password': password + } + ) + + def update_account(self, account, nickname, password): + """ + 更新客服账号 + 详情请参考 + http://mp.weixin.qq.com/wiki/1/70a29afed17f56d537c833f89be979c9.html + + :param account: 完整客服账号,格式为:账号前缀@公众号微信号 + :param nickname: 客服昵称,最长6个汉字或12个英文字符 + :param password: 客服账号登录密码 + :return: 返回的 JSON 数据包 + """ + password = to_binary(password) + password = hashlib.md5(password).hexdigest() + return self._post( + 'https://api.weixin.qq.com/customservice/kfaccount/update', + data={ + 'kf_account': account, + 'nickname': nickname, + 'password': password + } + ) + + def delete_account(self, account): + """ + 删除客服账号 + 详情请参考 + http://mp.weixin.qq.com/wiki/1/70a29afed17f56d537c833f89be979c9.html + + :param account: 完整客服账号,格式为:账号前缀@公众号微信号 + :return: 返回的 JSON 数据包 + """ + params_data = [ + 'access_token={0}'.format(quote(self.access_token)), + 'kf_account={0}'.format(quote(to_binary(account), safe=b'/@')), + ] + params = '&'.join(params_data) + return self._get( + 'https://api.weixin.qq.com/customservice/kfaccount/del', + params=params + ) + + def get_accounts(self): + """ + 获取客服账号列表 + 详情请参考 + http://mp.weixin.qq.com/wiki/1/70a29afed17f56d537c833f89be979c9.html + + :return: 客服账号列表 + """ + res = self._get( + 'customservice/getkflist', + result_processor=lambda x: x['kf_list'] + ) + return res + + def upload_headimg(self, account, media_file): + """ + 上传客服账号头像 + 详情请参考 + http://mp.weixin.qq.com/wiki/1/70a29afed17f56d537c833f89be979c9.html + + :param account: 完整客服账号 + :param media_file: 要上传的头像文件,一个 File-Object + :return: 返回的 JSON 数据包 + """ + return self._post( + 'https://api.weixin.qq.com/customservice/kfaccount/uploadheadimg', + params={ + 'kf_account': account + }, + files={ + 'media': media_file + } + ) + + def get_online_accounts(self): + """ + 获取在线客服接待信息 + 详情请参考 + http://mp.weixin.qq.com/wiki/9/6fff6f191ef92c126b043ada035cc935.html + + :return: 客服接待信息列表 + """ + res = self._get( + 'customservice/getonlinekflist', + result_processor=lambda x: x['kf_online_list'] + ) + return res + + def create_session(self, openid, account, text=None): + """ + 多客服创建会话 + 详情请参考 + http://mp.weixin.qq.com/wiki/2/6c20f3e323bdf5986cfcb33cbd3b829a.html + + :param openid: 客户 openid + :param account: 完整客服账号 + :param text: 附加信息,可选 + :return: 返回的 JSON 数据包 + """ + data = optionaldict( + openid=openid, + kf_account=account, + text=text + ) + return self._post( + 'https://api.weixin.qq.com/customservice/kfsession/create', + data=data + ) + + def close_session(self, openid, account, text=None): + """ + 多客服关闭会话 + 详情请参考 + http://mp.weixin.qq.com/wiki/2/6c20f3e323bdf5986cfcb33cbd3b829a.html + + :param openid: 客户 openid + :param account: 完整客服账号 + :param text: 附加信息,可选 + :return: 返回的 JSON 数据包 + """ + data = optionaldict( + openid=openid, + kf_account=account, + text=text + ) + return self._post( + 'https://api.weixin.qq.com/customservice/kfsession/close', + data=data + ) + + def get_session(self, openid): + """ + 获取客户的会话状态 + 详情请参考 + http://mp.weixin.qq.com/wiki/2/6c20f3e323bdf5986cfcb33cbd3b829a.html + + :param openid: 客户 openid + :return: 返回的 JSON 数据包 + """ + return self._get( + 'https://api.weixin.qq.com/customservice/kfsession/getsession', + params={'openid': openid} + ) + + def get_session_list(self, account): + """ + 获取客服的会话列表 + 详情请参考 + http://mp.weixin.qq.com/wiki/2/6c20f3e323bdf5986cfcb33cbd3b829a.html + + :param account: 完整客服账号 + :return: 客服的会话列表 + """ + res = self._get( + 'https://api.weixin.qq.com/customservice/kfsession/getsessionlist', + params={'kf_account': account}, + result_processor=lambda x: x['sessionlist'] + ) + return res + + def get_wait_case(self): + """ + 获取未接入会话列表 + 详情请参考 + http://mp.weixin.qq.com/wiki/2/6c20f3e323bdf5986cfcb33cbd3b829a.html + + :return: 返回的 JSON 数据包 + """ + return self._get( + 'https://api.weixin.qq.com/customservice/kfsession/getwaitcase' + ) + + def get_records(self, start_time, end_time, page_index, + page_size=10, user_id=None): + """ + 获取客服聊天记录 + 详情请参考 + http://mp.weixin.qq.com/wiki/19/7c129ec71ddfa60923ea9334557e8b23.html + + :param start_time: 查询开始时间,UNIX 时间戳 + :param end_time: 查询结束时间,UNIX 时间戳,每次查询不能跨日查询 + :param page_index: 查询第几页,从 1 开始 + :param page_size: 每页大小,每页最多拉取 1000 条 + :param user_id: 普通用户的标识,对当前公众号唯一 + + :return: 返回的 JSON 数据包 + """ + if isinstance(start_time, datetime.datetime): + start_time = time.mktime(start_time.timetuple()) + if isinstance(end_time, datetime.datetime): + end_time = time.mktime(end_time.timetuple()) + record_data = { + 'starttime': int(start_time), + 'endtime': int(end_time), + 'pageindex': page_index, + 'pagesize': page_size + } + if user_id: + record_data['openid'] = user_id + res = self._post( + 'https://api.weixin.qq.com/customservice/msgrecord/getrecord', + data=record_data, + result_processor=lambda x: x['recordlist'] + ) + return res diff --git a/sg_wechat_enterprise/we_api/client/api/datacube.py b/sg_wechat_enterprise/we_api/client/api/datacube.py new file mode 100644 index 00000000..f6d679c8 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/datacube.py @@ -0,0 +1,360 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import datetime + +import six + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatDataCube(BaseWeChatAPI): + + API_BASE_URL = 'https://api.weixin.qq.com/datacube/' + + @classmethod + def _to_date_str(cls, date): + if isinstance(date, (datetime.datetime, datetime.date)): + return date.strftime('%Y-%m-%d') + elif isinstance(date, six.string_types): + return date + else: + raise ValueError('Can not convert %s type to str', type(date)) + + def get_user_summary(self, begin_date, end_date): + """ + 获取用户增减数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/3/ecfed6e1a0a03b5f35e5efac98e864b7.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getusersummary', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + } + ) + return res['list'] + + def get_user_cumulate(self, begin_date, end_date): + """ + 获取累计用户数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/3/ecfed6e1a0a03b5f35e5efac98e864b7.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getusercumulate', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + }, + result_processor=lambda x: x['list'] + ) + return res + + def get_interface_summary(self, begin_date, end_date): + """ + 获取接口分析数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/8/30ed81ae38cf4f977194bf1a5db73668.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getinterfacesummary', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + }, + result_processor=lambda x: x['list'] + ) + return res + + def get_interface_summary_hour(self, begin_date, end_date): + """ + 获取接口分析分时数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/8/30ed81ae38cf4f977194bf1a5db73668.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getinterfacesummaryhour', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + }, + result_processor=lambda x: x['list'] + ) + return res + + def get_article_summary(self, begin_date, end_date): + """ + 获取图文群发每日数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/8/c0453610fb5131d1fcb17b4e87c82050.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getarticlesummary', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + }, + result_processor=lambda x: x['list'] + ) + return res + + def get_article_total(self, begin_date, end_date): + """ + 获取图文群发总数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/8/c0453610fb5131d1fcb17b4e87c82050.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getarticletotal', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + }, + result_processor=lambda x: x['list'] + ) + return res + + def get_user_read(self, begin_date, end_date): + """ + 获取图文统计数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/8/c0453610fb5131d1fcb17b4e87c82050.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getuserread', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + }, + result_processor=lambda x: x['list'] + ) + return res + + def get_user_read_hour(self, begin_date, end_date): + """ + 获取图文分时统计数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/8/c0453610fb5131d1fcb17b4e87c82050.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getuserreadhour', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + }, + result_processor=lambda x: x['list'] + ) + return res + + def get_user_share(self, begin_date, end_date): + """ + 获取图文分享转发数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/8/c0453610fb5131d1fcb17b4e87c82050.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getusershare', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + }, + result_processor=lambda x: x['list'] + ) + return res + + def get_user_share_hour(self, begin_date, end_date): + """ + 获取图文分享转发分时数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/8/c0453610fb5131d1fcb17b4e87c82050.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getusersharehour', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + }, + result_processor=lambda x: x['list'] + ) + return res + + def get_upstream_msg(self, begin_date, end_date): + """ + 获取消息发送概况数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/12/32d42ad542f2e4fc8a8aa60e1bce9838.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getupstreammsg', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + }, + result_processor=lambda x: x['list'] + ) + return res + + def get_upstream_msg_hour(self, begin_date, end_date): + """ + 获取消息发送分时数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/12/32d42ad542f2e4fc8a8aa60e1bce9838.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getupstreammsghour', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + }, + result_processor=lambda x: x['list'] + ) + return res + + def get_upstream_msg_week(self, begin_date, end_date): + """ + 获取消息发送周数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/12/32d42ad542f2e4fc8a8aa60e1bce9838.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getupstreammsgweek', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + }, + result_processor=lambda x: x['list'] + ) + return res + + def get_upstream_msg_month(self, begin_date, end_date): + """ + 获取消息发送月数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/12/32d42ad542f2e4fc8a8aa60e1bce9838.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getupstreammsgmonth', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + }, + result_processor=lambda x: x['list'] + ) + return res + + def get_upstream_msg_dist(self, begin_date, end_date): + """ + 获取消息发送分布数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/12/32d42ad542f2e4fc8a8aa60e1bce9838.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getupstreammsgdist', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + }, + result_processor=lambda x: x['list'] + ) + return res + + def get_upstream_msg_dist_week(self, begin_date, end_date): + """ + 获取消息发送分布数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/12/32d42ad542f2e4fc8a8aa60e1bce9838.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getupstreammsgdistweek', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + }, + result_processor=lambda x: x['list'] + ) + return res + + def get_upstream_msg_dist_month(self, begin_date, end_date): + """ + 获取消息发送分布数据 + 详情请参考 + http://mp.weixin.qq.com/wiki/12/32d42ad542f2e4fc8a8aa60e1bce9838.html + + :param begin_date: 起始日期 + :param end_date: 结束日期 + :return: 统计数据列表 + """ + res = self._post( + 'getupstreammsgdistmonth', + data={ + 'begin_date': self._to_date_str(begin_date), + 'end_date': self._to_date_str(end_date) + }, + result_processor=lambda x: x['list'] + ) + return res diff --git a/sg_wechat_enterprise/we_api/client/api/device.py b/sg_wechat_enterprise/we_api/client/api/device.py new file mode 100644 index 00000000..56b5a133 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/device.py @@ -0,0 +1,284 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import base64 +import urllib + +from wechatpy.utils import to_text, to_binary +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatDevice(BaseWeChatAPI): + + API_BASE_URL = 'https://api.weixin.qq.com/device/' + + def send_message(self, device_type, device_id, user_id, content): + """ + 主动发送消息给设备 + 详情请参考 + http://iot.weixin.qq.com/document-2_3.html + + :param device_type: 设备类型,目前为“公众账号原始ID” + :param device_id: 设备ID + :param user_id: 微信用户账号的openid + :param content: 消息内容,BASE64编码 + :return: 返回的 JSON 数据包 + """ + content = to_text(base64.b64encode(to_binary(content))) + return self._post( + 'transmsg', + data={ + 'device_type': device_type, + 'device_id': device_id, + 'openid': user_id, + 'content': content + } + ) + + def create_qrcode(self, device_ids): + """ + 获取设备二维码 + 详情请参考 + http://iot.weixin.qq.com/document-2_5.html + + :param device_ids: 设备id的列表 + :return: 返回的 JSON 数据包 + """ + return self._post( + 'create_qrcode', + data={ + 'device_num': len(device_ids), + 'device_id_list': device_ids + } + ) + + def get_qrcode_url(self, ticket, data=None): + """ + 通过 ticket 换取二维码地址 + 详情请参考 + http://iot.weixin.qq.com/document-2_5.html + + :param ticket: 二维码 ticket + :param data: 额外数据 + :return: 二维码地址 + """ + url = 'http://we.qq.com/d/{ticket}'.format(ticket=ticket) + if data: + if isinstance(data, (dict, tuple, list)): + data = urllib.urlencode(data) + data = to_text(base64.b64encode(to_binary(data))) + url = '{base}#{data}'.format(base=url, data=data) + return url + + def bind(self, ticket, device_id, user_id): + """ + 绑定设备 + 详情请参考 + http://iot.weixin.qq.com/document-2_12.html + + :param ticket: 绑定操作合法性的凭证(由微信后台生成,第三方H5通过客户端jsapi获得) + :param device_id: 设备id + :param user_id: 用户对应的openid + :return: 返回的 JSON 数据包 + """ + return self._post( + 'bind', + data={ + 'ticket': ticket, + 'device_id': device_id, + 'openid': user_id + } + ) + + def unbind(self, ticket, device_id, user_id): + """ + 解绑设备 + 详情请参考 + http://iot.weixin.qq.com/document-2_12.html + + :param ticket: 绑定操作合法性的凭证(由微信后台生成,第三方H5通过客户端jsapi获得) + :param device_id: 设备id + :param user_id: 用户对应的openid + :return: 返回的 JSON 数据包 + """ + return self._post( + 'unbind', + data={ + 'ticket': ticket, + 'device_id': device_id, + 'openid': user_id + } + ) + + def compel_bind(self, device_id, user_id): + """ + 强制绑定用户和设备 + 详情请参考 + http://iot.weixin.qq.com/document-2_12.html + + :param device_id: 设备id + :param user_id: 用户对应的openid + :return: 返回的 JSON 数据包 + """ + return self._post( + 'compel_bind', + data={ + 'device_id': device_id, + 'openid': user_id + } + ) + + force_bind = compel_bind + + def compel_unbind(self, device_id, user_id): + """ + 强制解绑用户和设备 + 详情请参考 + http://iot.weixin.qq.com/document-2_12.html + + :param device_id: 设备id + :param user_id: 用户对应的openid + :return: 返回的 JSON 数据包 + """ + return self._post( + 'compel_unbind', + data={ + 'device_id': device_id, + 'openid': user_id + } + ) + + force_unbind = compel_unbind + + def get_stat(self, device_id): + """ + 设备状态查询 + 详情请参考 + http://iot.weixin.qq.com/document-2_7.html + + :param device_id: 设备id + :return: 返回的 JSON 数据包 + """ + return self._post( + 'get_stat', + data={'device_id': device_id} + ) + + def verify_qrcode(self, ticket): + """ + 验证二维码 + 详情请参考 + http://iot.weixin.qq.com/document-2_9.html + + :param ticket: 设备二维码的ticket + :return: 返回的 JSON 数据包 + """ + return self._post( + 'verify_qrcode', + data={'ticket': ticket} + ) + + def get_user_id(self, device_type, device_id): + """ + 获取设备绑定openID + 详情请参考 + http://iot.weixin.qq.com/document-2_4.html + + :param device_type: 设备类型,目前为“公众账号原始ID” + :param device_id: 设备id + :return: 返回的 JSON 数据包 + """ + return self._post( + 'get_openid', + data={ + 'device_type': device_type, + 'device_id': device_id + } + ) + + get_open_id = get_user_id + + def get_binded_devices(self, user_id): + """ + 通过openid获取用户在当前devicetype下绑定的deviceid列表 + 详情请参考 + http://iot.weixin.qq.com/document-2_13.html + + :param user_id: 要查询的用户的openid + :return: 返回的 JSON 数据包 + """ + return self._post( + 'get_bind_device', + data={'openid': user_id} + ) + + get_bind_device = get_binded_devices + + def send_status_message(self, device_type, device_id, user_id, status): + """ + 主动发送设备状态消息给微信终端 + 详情请参考 + http://iot.weixin.qq.com/document-2_10.html + + :param device_type: 设备类型,目前为“公众账号原始ID” + :param device_id: 设备ID + :param user_id: 微信用户账号的openid + :param status: 设备状态:0--未连接, 1--已连接 + :return: 返回的 JSON 数据包 + """ + return self._post( + 'transmsg', + data={ + 'device_type': device_type, + 'device_id': device_id, + 'open_id': user_id, + 'device_status': status + } + ) + + def authorize(self, devices, op_type=0): + """ + 设备授权 + 详情请参考 + http://iot.weixin.qq.com/document-2_6.html + + :param devices: 设备信息的列表 + :param op_type: 请求操作的类型,限定取值为:0:设备授权 1:设备更新 + :return: 返回的 JSON 数据包 + """ + return self._post( + 'authorize', + data={ + 'device_num': len(devices), + 'device_list': devices, + 'op_type': op_type + } + ) + + def get_qrcode(self): + """ + 获取deviceid和二维码 + 详情请参考 + http://iot.weixin.qq.com/document-2_11.html + + :return: 返回的 JSON 数据包 + """ + return self._get('getqrcode') + + def authorize_device(self, devices, op_type=1): + """ + 设备授权 + 详情请参考 + http://iot.weixin.qq.com/document-2_6.html + + :param devices: 设备信息的列表 + :param op_type: 请求操作的类型,限定取值为:0:设备授权 1:设备更新 + :return: 返回的 JSON 数据包 + """ + return self._post( + 'authorize_device', + data={ + 'device_num': len(devices), + 'device_list': devices, + 'op_type': op_type + } + ) diff --git a/sg_wechat_enterprise/we_api/client/api/group.py b/sg_wechat_enterprise/we_api/client/api/group.py new file mode 100644 index 00000000..90600916 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/group.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +from wechatpy.utils import to_text +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatGroup(BaseWeChatAPI): + + def create(self, name): + """ + 创建分组 + + 详情请参考 + http://mp.weixin.qq.com/wiki/0/56d992c605a97245eb7e617854b169fc.html + + :param name: 分组名字(30个字符以内) + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.group.create('New Group') + + """ + name = to_text(name) + return self._post( + 'groups/create', + data={'group': {'name': name}} + ) + + def get(self, user_id=None): + """ + 查询所有分组或查询用户所在分组 ID + + 详情请参考 + http://mp.weixin.qq.com/wiki/0/56d992c605a97245eb7e617854b169fc.html + + :param user_id: 用户 ID,提供时查询该用户所在分组,否则查询所有分组 + :return: 所有分组列表或用户所在分组 ID + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + group = client.group.get('openid') + + """ + if user_id is None: + res = self._get( + 'groups/get', + result_processor=lambda x: x['groups'] + ) + else: + res = self._post( + 'groups/getid', + data={'openid': user_id}, + result_processor=lambda x: x['groupid'] + ) + return res + + def update(self, group_id, name): + """ + 修改分组名 + + 详情请参考 + http://mp.weixin.qq.com/wiki/0/56d992c605a97245eb7e617854b169fc.html + + :param group_id: 分组id,由微信分配 + :param name: 分组名字(30个字符以内) + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.group.update(1234, 'New Name') + + """ + name = to_text(name) + return self._post( + 'groups/update', + data={ + 'group': { + 'id': int(group_id), + 'name': name + } + } + ) + + def move_user(self, user_id, group_id): + """ + 移动用户分组 + + 详情请参考 + http://mp.weixin.qq.com/wiki/0/56d992c605a97245eb7e617854b169fc.html + + :param user_id: 用户 ID, 可以是单个或者列表,为列表时为批量移动用户分组 + :param group_id: 分组 ID + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.group.move_user('openid', 1234) + + """ + data = {'to_groupid': group_id} + if isinstance(user_id, (tuple, list)): + endpoint = 'groups/members/batchupdate' + data['openid_list'] = user_id + else: + endpoint = 'groups/members/update' + data['openid'] = user_id + return self._post(endpoint, data=data) + + def delete(self, group_id): + """ + 删除分组 + + 详情请参考 + http://mp.weixin.qq.com/wiki/0/56d992c605a97245eb7e617854b169fc.html + + :param group_id: 分组 ID + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.group.delete(1234) + + """ + return self._post( + 'groups/delete', + data={ + 'group': { + 'id': group_id + } + } + ) diff --git a/sg_wechat_enterprise/we_api/client/api/jsapi.py b/sg_wechat_enterprise/we_api/client/api/jsapi.py new file mode 100644 index 00000000..9c444176 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/jsapi.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +""" + wechatpy.client.jsapi + ~~~~~~~~~~~~~~~~~~~~ + + This module provides some APIs for JS SDK + + :copyright: (c) 2014 by messense. + :license: MIT, see LICENSE for more details. +""" +from __future__ import absolute_import, unicode_literals +import time + +from wechatpy.utils import WeChatSigner +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatJSAPI(BaseWeChatAPI): + + def get_ticket(self, type='jsapi'): + """ + 获取微信 JS-SDK ticket + + :return: 返回的 JSON 数据包 + """ + return self._get( + 'ticket/getticket', + params={'type': type} + ) + + def get_jsapi_ticket(self): + """ + 获取微信 JS-SDK ticket + + 该方法会通过 session 对象自动缓存管理 ticket + + :return: ticket + """ + ticket = self.session.get('jsapi_ticket') + expires_at = self.session.get('jsapi_ticket_expires_at', 0) + if not ticket or expires_at < int(time.time()): + jsapi_ticket = self.get_ticket('jsapi') + ticket = jsapi_ticket['ticket'] + expires_at = int(time.time()) + int(jsapi_ticket['expires_in']) + self.session.set('jsapi_ticket', ticket) + self.session.set('jsapi_ticket_expires_at', expires_at) + return ticket + + def get_jsapi_signature(self, noncestr, ticket, timestamp, url): + data = [ + 'noncestr={noncestr}'.format(noncestr=noncestr), + 'jsapi_ticket={ticket}'.format(ticket=ticket), + 'timestamp={timestamp}'.format(timestamp=timestamp), + 'url={url}'.format(url=url), + ] + signer = WeChatSigner(delimiter=b'&') + signer.add_data(*data) + return signer.signature diff --git a/sg_wechat_enterprise/we_api/client/api/material.py b/sg_wechat_enterprise/we_api/client/api/material.py new file mode 100644 index 00000000..cf40fde0 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/material.py @@ -0,0 +1,167 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.utils import json +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatMaterial(BaseWeChatAPI): + + def add_articles(self, articles): + """ + 新增永久图文素材 + 详情请参考 + http://mp.weixin.qq.com/wiki/14/7e6c03263063f4813141c3e17dd4350a.html + + :param articles: 图文素材数组 + :return: 返回的 JSON 数据包 + """ + articles_data = [] + for article in articles: + articles_data.append({ + 'thumb_media_id': article['thumb_media_id'], + 'title': article['title'], + 'content': article['content'], + 'author': article.get('author', ''), + 'content_source_url': article.get('content_source_url', ''), + 'digest': article.get('digest', ''), + 'show_cover_pic': article.get('show_cover_pic', '0') + }) + return self._post( + 'material/add_news', + data={ + 'articles': articles_data + } + ) + + def add(self, media_type, media_file, title=None, introduction=None): + """ + 新增其它类型永久素材 + 详情请参考 + http://mp.weixin.qq.com/wiki/14/7e6c03263063f4813141c3e17dd4350a.html + + :param media_type: 媒体文件类型,分别有图片(image)、语音(voice)、视频(video)和缩略图(thumb) + :param media_file: 要上传的文件,一个 File-object + :param title: 视频素材标题,仅上传视频素材时需要 + :param introduction: 视频素材简介,仅上传视频素材时需要 + :return: 返回的 JSON 数据包 + """ + params = { + 'access_token': self.access_token, + 'type': media_type + } + if media_type == 'video': + assert title, 'Video title must be set' + assert introduction, 'Video introduction must be set' + description = { + 'title': title, + 'introduction': introduction + } + params['description'] = json.dumps(description) + return self._post( + 'material/add_material', + params=params, + files={ + 'media': media_file + } + ) + + def get(self, media_id): + """ + 获取永久素材 + 详情请参考 + http://mp.weixin.qq.com/wiki/4/b3546879f07623cb30df9ca0e420a5d0.html + + :param media_id: 素材的 media_id + :return: 图文素材返回图文列表,其它类型为素材的内容 + """ + def _processor(res): + if isinstance(res, dict): + # 图文素材 + return res.get('news_item', []) + return res + + res = self._post( + 'material/get_material', + data={ + 'media_id': media_id + }, + result_processor=_processor + ) + return res + + def delete(self, media_id): + """ + 删除永久素材 + 详情请参考 + http://mp.weixin.qq.com/wiki/5/e66f61c303db51a6c0f90f46b15af5f5.html + + :param media_id: 素材的 media_id + :return: 返回的 JSON 数据包 + """ + return self._post( + 'material/del_material', + data={ + 'media_id': media_id + } + ) + + def update_articles(self, media_id, index, articles): + """ + 修改永久图文素材 + 详情请参考 + http://mp.weixin.qq.com/wiki/4/19a59cba020d506e767360ca1be29450.html + + :param media_id: 要修改的图文消息的 id + :param index: 要更新的文章在图文消息中的位置(多图文消息时,此字段才有意义),第一篇为 0 + :param articles: 图文素材数组 + :return: 返回的 JSON 数据包 + """ + articles_data = [] + for article in articles: + articles_data.append({ + 'thumb_media_id': article['thumb_media_id'], + 'title': article['title'], + 'content': article['content'], + 'author': article.get('author', ''), + 'content_source_url': article.get('content_source_url', ''), + 'digest': article.get('digest', ''), + 'show_cover_pic': article.get('show_cover_pic', '0') + }) + return self._post( + 'material/update_news', + data={ + 'media_id': media_id, + 'index': index, + 'articles': articles_data + } + ) + + def batchget(self, media_type, offset=0, count=20): + """ + 批量获取永久素材列表 + 详情请参考 + http://mp.weixin.qq.com/wiki/12/2108cd7aafff7f388f41f37efa710204.html + + :param media_type: 媒体文件类型,分别有图片(image)、语音(voice)、视频(video)和缩略图(news) + :param offset: 从全部素材的该偏移位置开始返回,0 表示从第一个素材返回 + :param count: 返回素材的数量,取值在1到20之间 + :return: 返回的 JSON 数据包 + """ + return self._post( + 'material/batchget_material', + data={ + 'type': media_type, + 'offset': offset, + 'count': count + } + ) + + def get_count(self): + """ + 获取素材总数 + 详情请参考 + http://mp.weixin.qq.com/wiki/16/8cc64f8c189674b421bee3ed403993b8.html + + :return: 返回的 JSON 数据包 + """ + return self._get('material/get_materialcount') diff --git a/sg_wechat_enterprise/we_api/client/api/media.py b/sg_wechat_enterprise/we_api/client/api/media.py new file mode 100644 index 00000000..e04317b2 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/media.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatMedia(BaseWeChatAPI): + + def upload(self, media_type, media_file): + """ + 上传临时素材 + 详情请参考 + http://mp.weixin.qq.com/wiki/5/963fc70b80dc75483a271298a76a8d59.html + + :param media_type: 媒体文件类型,分别有图片(image)、语音(voice)、视频(video)和缩略图(thumb) + :param media_file: 要上传的文件,一个 File-object + + :return: 返回的 JSON 数据包 + """ + return self._post( + url='http://file.api.weixin.qq.com/cgi-bin/media/upload', + params={ + 'type': media_type + }, + files={ + 'media': media_file + } + ) + + def download(self, media_id): + """ + 获取临时素材 + 详情请参考 + http://mp.weixin.qq.com/wiki/10/78b15308b053286e2a66b33f0f0f5fb6.html + + :param media_id: 媒体文件 ID + + :return: requests 的 Response 实例 + """ + return self._get( + 'http://file.api.weixin.qq.com/cgi-bin/media/get', + params={ + 'media_id': media_id + } + ) + + def get_url(self, media_id): + """ + 获取临时素材下载地址 + + :param media_id: 媒体文件 ID + :return: 临时素材下载地址 + """ + parts = ( + 'http://file.api.weixin.qq.com/cgi-bin/media/get', + '?access_token=', + self.access_token, + '&media_id=', + media_id + ) + return ''.join(parts) + + def upload_video(self, media_id, title, description): + """ + 群发视频消息时获取视频 media_id + 详情请参考 + http://mp.weixin.qq.com/wiki/15/5380a4e6f02f2ffdc7981a8ed7a40753.html + + :param media_id: 需通过基础支持中的上传下载多媒体文件 :func:`upload` 来得到 + :param title: 视频标题 + :param description: 视频描述 + + :return: 返回的 JSON 数据包 + """ + return self._post( + url='https://file.api.weixin.qq.com/cgi-bin/media/uploadvideo', + data={ + 'media_id': media_id, + 'title': title, + 'description': description + } + ) + + def upload_articles(self, articles): + """ + 上传图文消息素材 + 详情请参考 + http://mp.weixin.qq.com/wiki/15/5380a4e6f02f2ffdc7981a8ed7a40753.html + + :param articles: 图文消息数组 + :return: 返回的 JSON 数据包 + """ + articles_data = [] + for article in articles: + articles_data.append({ + 'thumb_media_id': article['thumb_media_id'], + 'title': article['title'], + 'content': article['content'], + 'author': article.get('author', ''), + 'content_source_url': article.get('content_source_url', ''), + 'digest': article.get('digest', ''), + 'show_cover_pic': article.get('show_cover_pic', '0') + }) + return self._post( + 'media/uploadnews', + data={ + 'articles': articles_data + } + ) + + def upload_mass_image(self, media_file): + """ + 上传群发消息内的图片 + 详情请参考 + http://mp.weixin.qq.com/wiki/15/5380a4e6f02f2ffdc7981a8ed7a40753.html + + :param media_file: 要上传的文件,一个 File-object + :return: 上传成功时返回图片 URL + """ + res = self._post( + url='https://api.weixin.qq.com/cgi-bin/media/uploadimg', + files={ + 'media': media_file + }, + result_processor=lambda x: x['url'] + ) + return res diff --git a/sg_wechat_enterprise/we_api/client/api/menu.py b/sg_wechat_enterprise/we_api/client/api/menu.py new file mode 100644 index 00000000..e355e8ad --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/menu.py @@ -0,0 +1,235 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +from wechatpy.exceptions import WeChatClientException +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatMenu(BaseWeChatAPI): + + def get(self): + """ + 查询自定义菜单。 + 详情请参考 + http://mp.weixin.qq.com/wiki/16/ff9b7b85220e1396ffa16794a9d95adc.html + + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + menu = client.menu.get() + + """ + try: + return self._get('menu/get') + except WeChatClientException as e: + if e.errcode == 46003: + # menu not exist + return None + else: + raise e + + def create(self, menu_data): + """ + 创建自定义菜单 :: + + from wechatpy import WeChatClient + + client = WeChatClient("appid", "secret") + client.menu.create({ + "button":[ + { + "type":"click", + "name":"今日歌曲", + "key":"V1001_TODAY_MUSIC" + }, + { + "type":"click", + "name":"歌手简介", + "key":"V1001_TODAY_SINGER" + }, + { + "name":"菜单", + "sub_button":[ + { + "type":"xml", + "name":"搜索", + "url":"http://www.soso.com/" + }, + { + "type":"xml", + "name":"视频", + "url":"http://v.qq.com/" + }, + { + "type":"click", + "name":"赞一下我们", + "key":"V1001_GOOD" + } + ] + } + ] + }) + + 详情请参考 + https://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1421141013 + + :param menu_data: Python 字典 + + :return: 返回的 JSON 数据包 + """ + return self._post( + 'menu/create', + data=menu_data + ) + + update = create + + def delete(self): + """ + 删除自定义菜单。 + 详情请参考 + http://mp.weixin.qq.com/wiki/16/8ed41ba931e4845844ad6d1eeb8060c8.html + + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.menu.delete() + + """ + return self._get('menu/delete') + + def get_menu_info(self): + """ + 获取自定义菜单配置 + 详情请参考 + http://mp.weixin.qq.com/wiki/17/4dc4b0514fdad7a5fbbd477aa9aab5ed.html + + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + menu_info = client.menu.get_menu_info() + + """ + return self._get('get_current_selfmenu_info') + + def add_conditional(self, menu_data): + """ + 创建个性化菜单 :: + + from wechatpy import WeChatClient + + client = WeChatClient("appid", "secret") + client.menu.add_conditional({ + "button":[ + { + "type":"click", + "name":"今日歌曲", + "key":"V1001_TODAY_MUSIC" + }, + { + "type":"click", + "name":"歌手简介", + "key":"V1001_TODAY_SINGER" + }, + { + "name":"菜单", + "sub_button":[ + { + "type":"xml", + "name":"搜索", + "url":"http://www.soso.com/" + }, + { + "type":"xml", + "name":"视频", + "url":"http://v.qq.com/" + }, + { + "type":"click", + "name":"赞一下我们", + "key":"V1001_GOOD" + } + ] + } + ], + "matchrule":{ + "group_id":"2", + "sex":"1", + "country":"中国", + "province":"广东", + "city":"广州", + "client_platform_type":"2" + } + }) + + 详情请参考 + http://mp.weixin.qq.com/wiki/0/c48ccd12b69ae023159b4bfaa7c39c20.html + + :param menu_data: Python 字典 + + :return: 返回的 JSON 数据包 + """ + return self._post( + 'menu/addconditional', + data=menu_data + ) + + def del_conditional(self, menu_id): + """ + 删除个性化菜单 + + 详情请参考 + http://mp.weixin.qq.com/wiki/0/c48ccd12b69ae023159b4bfaa7c39c20.html + + :param menu_id: 菜单ID + + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.menu.del_conditional('menu_id') + + """ + return self._post( + 'menu/delconditional', + data={'menuid': menu_id} + ) + + def try_match(self, user_id): + """ + 测试个性化菜单匹配结果 + + 详情请参考 + http://mp.weixin.qq.com/wiki/0/c48ccd12b69ae023159b4bfaa7c39c20.html + + :param user_id: 可以是粉丝的OpenID,也可以是粉丝的微信号。 + + :return: 该接口将返回菜单配置 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.menu.try_match('openid') + + """ + return self._post( + 'menu/trymatch', + data={'user_id': user_id} + ) diff --git a/sg_wechat_enterprise/we_api/client/api/merchant/__init__.py b/sg_wechat_enterprise/we_api/client/api/merchant/__init__.py new file mode 100644 index 00000000..93899db7 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/merchant/__init__.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.client.api.base import BaseWeChatAPI + +from wechatpy.client.api.merchant.category import MerchantCategory +from wechatpy.client.api.merchant.stock import MerchantStock +from wechatpy.client.api.merchant.express import MerchantExpress +from wechatpy.client.api.merchant.group import MerchantGroup +from wechatpy.client.api.merchant.shelf import MerchantShelf +from wechatpy.client.api.merchant.order import MerchantOrder +from wechatpy.client.api.merchant.common import MerchantCommon + + +class WeChatMerchant(BaseWeChatAPI): + + def __init__(self, *args, **kwargs): + super(WeChatMerchant, self).__init__(*args, **kwargs) + + # sub APIs + self.category = MerchantCategory(self._client) + self.stock = MerchantStock(self._client) + self.express = MerchantExpress(self._client) + self.group = MerchantGroup(self._client) + self.shelf = MerchantShelf(self._client) + self.order = MerchantOrder(self._client) + self.common = MerchantCommon(self._client) + + def create(self, product_data): + return self._post( + 'merchant/create', + data=product_data + ) + + def delete(self, product_id): + return self._post( + 'merchant/del', + data={ + 'product_id': product_id + } + ) + + def update(self, product_id, product_data): + product_data['product_id'] = product_id + return self._post( + 'merchant/update', + data=product_data + ) + + def get(self, product_id): + return self._post( + 'merchant/get', + data={ + 'product_id': product_id + } + ) + + def get_by_status(self, status): + return self._post( + 'merchant/getbystatus', + data={ + 'status': status + } + ) + + def update_product_status(self, product_id, status): + return self._post( + 'merchant/modproductstatus', + data={ + 'product_id': product_id, + 'status': status + } + ) diff --git a/sg_wechat_enterprise/we_api/client/api/merchant/category.py b/sg_wechat_enterprise/we_api/client/api/merchant/category.py new file mode 100644 index 00000000..b26377ed --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/merchant/category.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.client.api.base import BaseWeChatAPI + + +class MerchantCategory(BaseWeChatAPI): + + def get_sub_categories(self, cate_id): + res = self._post( + 'merchant/category/getsub', + data={'cate_id': cate_id}, + result_processor=lambda x: x['cate_list'] + ) + return res + + def get_sku_list(self, cate_id): + res = self._post( + 'merchant/category/getsku', + data={'cate_id': cate_id}, + result_processor=lambda x: x['sku_table'] + ) + return res + + def get_properties(self, cate_id): + res = self._post( + 'merchant/category/getproperty', + data={'cate_id': cate_id}, + result_processor=lambda x: x['properties'] + ) + return res diff --git a/sg_wechat_enterprise/we_api/client/api/merchant/common.py b/sg_wechat_enterprise/we_api/client/api/merchant/common.py new file mode 100644 index 00000000..9730a311 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/merchant/common.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.client.api.base import BaseWeChatAPI + + +class MerchantCommon(BaseWeChatAPI): + + def upload_image(self, filename, image_data): + res = self._post( + 'merchant/common/upload_img', + params={ + 'filename': filename + }, + data=image_data, + result_processor=lambda x: x['image_url'] + ) + return res diff --git a/sg_wechat_enterprise/we_api/client/api/merchant/express.py b/sg_wechat_enterprise/we_api/client/api/merchant/express.py new file mode 100644 index 00000000..27fe2b22 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/merchant/express.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.client.api.base import BaseWeChatAPI + + +class MerchantExpress(BaseWeChatAPI): + + def add(self, delivery_template): + return self._post( + 'merchant/express/add', + data={ + 'delivery_template': delivery_template + } + ) + + def delete(self, template_id): + return self._post( + 'merchant/express/del', + data={ + 'template_id': template_id + } + ) + + def update(self, template_id, delivery_template): + return self._post( + 'merchant/express/update', + data={ + 'template_id': template_id, + 'delivery_template': delivery_template + } + ) + + def get(self, template_id): + res = self._post( + 'merchant/express/getbyid', + data={ + 'template_id': template_id + }, + result_processor=lambda x: x['template_info'] + ) + return res + + def get_all(self): + res = self._get( + 'merchant/express/getall', + result_processor=lambda x: x['template_info'] + ) + return res diff --git a/sg_wechat_enterprise/we_api/client/api/merchant/group.py b/sg_wechat_enterprise/we_api/client/api/merchant/group.py new file mode 100644 index 00000000..593b3751 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/merchant/group.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.client.api.base import BaseWeChatAPI + + +class MerchantGroup(BaseWeChatAPI): + + def add(self, name, product_list): + return self._post( + 'merchant/group/add', + data={ + 'group_detail': { + 'group_name': name, + 'product_list': product_list + } + } + ) + + def delete(self, group_id): + return self._post( + 'merchant/group/del', + data={ + 'group_id': group_id + } + ) + + def update(self, group_id, name): + return self._post( + 'merchant/group/propertymod', + data={ + 'group_id': group_id, + 'group_name': name + } + ) + + def update_product(self, group_id, product): + return self._post( + 'merchant/group/productmod', + data={ + 'group_id': group_id, + 'product': product + } + ) + + def get_all(self): + res = self._get( + 'merchant/group/getall', + result_processor=lambda x: x['group_detail'] + ) + return res + + def get(self, group_id): + res = self._post( + 'merchant/group/getbyid', + data={ + 'group_id': group_id + }, + result_processor=lambda x: x['group_detail'] + ) + return res diff --git a/sg_wechat_enterprise/we_api/client/api/merchant/order.py b/sg_wechat_enterprise/we_api/client/api/merchant/order.py new file mode 100644 index 00000000..09d4b503 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/merchant/order.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from optionaldict import optionaldict +from wechatpy.client.api.base import BaseWeChatAPI + + +class MerchantOrder(BaseWeChatAPI): + + def get(self, order_id): + res = self._post( + 'merchant/order/getbyid', + data={ + 'order_id': order_id + }, + result_processor=lambda x: x['order'] + ) + return res + + def get_by_filter(self, status=None, begin_time=None, end_time=None): + filter_dict = optionaldict( + status=status, + begintime=begin_time, + endtime=end_time + ) + + res = self._post( + 'merchant/order/getbyfilter', + data=dict(filter_dict), + result_processor=lambda x: x['order_list'] + ) + return res + + def set_delivery(self, order_id, company, track_no, + need_delivery=1, is_others=0): + return self._post( + 'merchant/order/setdelivery', + data={ + 'order_id': order_id, + 'delivery_company': company, + 'delivery_track_no': track_no, + 'need_delivery': need_delivery, + 'is_others': is_others + } + ) + + def close(self, order_id): + return self._post( + 'merchant/order/close', + data={ + 'order_id': order_id + } + ) diff --git a/sg_wechat_enterprise/we_api/client/api/merchant/shelf.py b/sg_wechat_enterprise/we_api/client/api/merchant/shelf.py new file mode 100644 index 00000000..79354951 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/merchant/shelf.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.client.api.base import BaseWeChatAPI + + +class MerchantShelf(BaseWeChatAPI): + + def add(self, name, banner, shelf_data): + return self._post( + 'merchant/shelf/add', + data={ + 'shelf_name': name, + 'shelf_banner': banner, + 'shelf_data': shelf_data + } + ) + + def delete(self, shelf_id): + return self._post( + 'merchant/shelf/del', + data={ + 'shelf_id': shelf_id + } + ) + + def update(self, shelf_id, name, banner, shelf_data): + return self._post( + 'merchant/shelf/add', + data={ + 'shelf_id': shelf_id, + 'shelf_name': name, + 'shelf_banner': banner, + 'shelf_data': shelf_data + } + ) + + def get_all(self): + res = self._get( + 'merchant/shelf/getall', + result_processor=lambda x: x['shelves'] + ) + return res + + def get(self, shelf_id): + return self._post( + 'merchant/shelf/getbyid', + data={ + 'shelf_id': shelf_id + } + ) diff --git a/sg_wechat_enterprise/we_api/client/api/merchant/stock.py b/sg_wechat_enterprise/we_api/client/api/merchant/stock.py new file mode 100644 index 00000000..bfe09de6 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/merchant/stock.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.client.api.base import BaseWeChatAPI + + +class MerchantStock(BaseWeChatAPI): + + def add(self, product_id, quantity, sku_info=''): + return self._post( + 'merchant/stock/add', + data={ + 'product_id': product_id, + 'quantity': quantity, + 'sku_info': sku_info + } + ) + + def reduce(self, product_id, quantity, sku_info=''): + return self._post( + 'merchant/stock/reduce', + data={ + 'product_id': product_id, + 'quantity': quantity, + 'sku_info': sku_info + } + ) diff --git a/sg_wechat_enterprise/we_api/client/api/message.py b/sg_wechat_enterprise/we_api/client/api/message.py new file mode 100644 index 00000000..e1fb3e44 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/message.py @@ -0,0 +1,572 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import re +import six + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatMessage(BaseWeChatAPI): + OPENID_RE = re.compile(r'^[\w\-]{28}$', re.I) + + def _send_custom_message(self, data, account=None): + data = data or {} + if account: + data['customservice'] = {'kf_account': account} + return self._post( + 'message/custom/send', + data=data + ) + + def send_text(self, user_id, content, account=None): + """ + 发送文本消息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/7/12a5a320ae96fecdf0e15cb06123de9f.html + + :param user_id: 用户 ID 。 就是你收到的 `Message` 的 source + :param content: 消息正文 + :param account: 可选,客服账号 + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.message.send_text('openid', 'text') + + """ + data = { + 'touser': user_id, + 'msgtype': 'text', + 'text': {'content': content} + } + return self._send_custom_message(data, account=account) + + def send_image(self, user_id, media_id, account=None): + """ + 发送图片消息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/7/12a5a320ae96fecdf0e15cb06123de9f.html + + :param user_id: 用户 ID 。 就是你收到的 `Message` 的 source + :param media_id: 图片的媒体ID。 可以通过 :func:`upload_media` 上传。 + :param account: 可选,客服账号 + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.message.send_image('openid', 'media_id') + + """ + data = { + 'touser': user_id, + 'msgtype': 'image', + 'image': { + 'media_id': media_id + } + } + return self._send_custom_message(data, account=account) + + def send_voice(self, user_id, media_id, account=None): + """ + 发送语音消息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/7/12a5a320ae96fecdf0e15cb06123de9f.html + + :param user_id: 用户 ID 。 就是你收到的 `Message` 的 source + :param media_id: 发送的语音的媒体ID。 可以通过 :func:`upload_media` 上传。 + :param account: 可选,客服账号 + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.message.send_voice('openid', 'media_id') + + """ + data = { + 'touser': user_id, + 'msgtype': 'voice', + 'voice': { + 'media_id': media_id + } + } + return self._send_custom_message(data, account=account) + + def send_video(self, user_id, media_id, title=None, + description=None, account=None): + """ + 发送视频消息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/7/12a5a320ae96fecdf0e15cb06123de9f.html + + :param user_id: 用户 ID 。 就是你收到的 `Message` 的 source + :param media_id: 发送的视频的媒体ID。 可以通过 :func:`upload_media` 上传。 + :param title: 视频消息的标题 + :param description: 视频消息的描述 + :param account: 可选,客服账号 + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.message.send_video('openid', 'media_id', 'title', 'description') + """ + video_data = { + 'media_id': media_id, + } + if title: + video_data['title'] = title + if description: + video_data['description'] = description + + data = { + 'touser': user_id, + 'msgtype': 'video', + 'video': video_data + } + return self._send_custom_message(data, account=account) + + def send_music(self, user_id, url, hq_url, thumb_media_id, + title=None, description=None, account=None): + """ + 发送音乐消息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/7/12a5a320ae96fecdf0e15cb06123de9f.html + + :param user_id: 用户 ID 。 就是你收到的 `Message` 的 source + :param url: 音乐链接 + :param hq_url: 高品质音乐链接,wifi环境优先使用该链接播放音乐 + :param thumb_media_id: 缩略图的媒体ID。 可以通过 :func:`upload_media` 上传。 + :param title: 音乐标题 + :param description: 音乐描述 + :param account: 可选,客服账号 + :return: 返回的 JSON 数据包 + """ + music_data = { + 'musicurl': url, + 'hqmusicurl': hq_url, + 'thumb_media_id': thumb_media_id + } + if title: + music_data['title'] = title + if description: + music_data['description'] = description + + data = { + 'touser': user_id, + 'msgtype': 'music', + 'music': music_data + } + return self._send_custom_message(data, account=account) + + def send_articles(self, user_id, articles, account=None): + """ + 发送图文消息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/7/12a5a320ae96fecdf0e15cb06123de9f.html + + :param user_id: 用户 ID 。 就是你收到的 `Message` 的 source + :param articles: 一个包含至多10个图文的数组, 或者微信图文消息素材 media_id + :param account: 可选,客服账号 + :return: 返回的 JSON 数据包 + """ + if isinstance(articles, (tuple, list)): + articles_data = [] + for article in articles: + articles_data.append({ + 'title': article['title'], + 'description': article['description'], + 'url': article['url'], + 'picurl': article.get('image', article.get('picurl')), + }) + data = { + 'touser': user_id, + 'msgtype': 'news', + 'news': { + 'articles': articles_data + } + } + else: + data = { + 'touser': user_id, + 'msgtype': 'mpnews', + 'mpnews': { + 'media_id': articles, + } + } + return self._send_custom_message(data, account=account) + + def send_card(self, user_id, card_id, card_ext, account=None): + """ + 发送卡券消息 + + 详情请参参考 + http://mp.weixin.qq.com/wiki/1/70a29afed17f56d537c833f89be979c9.html + + :param user_id: 用户 ID 。 就是你收到的 `Message` 的 source + :param card_id: 卡券 ID + :param card_ext: 卡券扩展信息 + :param account: 可选,客服账号 + :return: 返回的 JSON 数据包 + """ + data = { + 'touser': user_id, + 'msgtype': 'wxcard', + 'wxcard': { + 'card_id': card_id, + 'card_ext': card_ext + } + } + return self._send_custom_message(data, account=account) + + def delete_mass(self, msg_id): + """ + 删除群发消息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/5380a4e6f02f2ffdc7981a8ed7a40753.html + + :param msg_id: 要删除的群发消息 ID + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.message.delete_mass('message id') + + """ + return self._post( + 'message/mass/delete', + data={ + 'msg_id': msg_id + } + ) + + def _send_mass_message(self, group_or_users, msg_type, msg, + is_to_all=False, preview=False): + data = { + 'msgtype': msg_type + } + if not preview: + if isinstance(group_or_users, (tuple, list)): + # send by user ids + data['touser'] = group_or_users + endpoint = 'message/mass/send' + else: + # send by group id + data['filter'] = { + 'group_id': group_or_users, + 'is_to_all': is_to_all, + } + endpoint = 'message/mass/sendall' + else: + if not isinstance(group_or_users, six.string_types): + raise ValueError('group_or_users should be string types') + # 预览接口 + if self.OPENID_RE.match(group_or_users): + # 按照 openid 预览群发 + data['touser'] = group_or_users + else: + # 按照微信号预览群发 + data['towxname'] = group_or_users + endpoint = 'message/mass/preview' + + data.update(msg) + return self._post( + endpoint, + data=data + ) + + def send_mass_text(self, group_or_users, content, + is_to_all=False, preview=False): + """ + 群发文本消息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/5380a4e6f02f2ffdc7981a8ed7a40753.html + + :param group_or_users: 值为整型数字时为按分组群发,值为列表/元组时为按 OpenID 列表群发 + :param content: 消息正文 + :param is_to_all: 用于设定是否向全部用户发送,值为true或false,选择true该消息群发给所有用户 + 选择false可根据group_id发送给指定群组的用户 + :param preview: 是否发送预览,此时 group_or_users 参数应为一个openid字符串 + + :return: 返回的 JSON 数据包 + """ + return self._send_mass_message( + group_or_users, + 'text', + { + 'text': { + 'content': content + } + }, + is_to_all, + preview + ) + + def send_mass_image(self, group_or_users, media_id, + is_to_all=False, preview=False): + """ + 群发图片消息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/5380a4e6f02f2ffdc7981a8ed7a40753.html + + :param group_or_users: 值为整型数字时为按分组群发,值为列表/元组时为按 OpenID 列表群发 + :param media_id: 图片的媒体 ID。 可以通过 :func:`upload_media` 上传。 + :param is_to_all: 用于设定是否向全部用户发送,值为true或false,选择true该消息群发给所有用户 + 选择false可根据group_id发送给指定群组的用户 + :param preview: 是否发送预览,此时 group_or_users 参数应为一个openid字符串 + + :return: 返回的 JSON 数据包 + """ + return self._send_mass_message( + group_or_users, + 'image', + { + 'image': { + 'media_id': media_id + } + }, + is_to_all, + preview + ) + + def send_mass_voice(self, group_or_users, media_id, + is_to_all=False, preview=False): + """ + 群发语音消息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/5380a4e6f02f2ffdc7981a8ed7a40753.html + + :param group_or_users: 值为整型数字时为按分组群发,值为列表/元组时为按 OpenID 列表群发 + :param media_id: 语音的媒体 ID。可以通过 :func:`upload_media` 上传。 + :param is_to_all: 用于设定是否向全部用户发送,值为true或false,选择true该消息群发给所有用户 + 选择false可根据group_id发送给指定群组的用户 + :param preview: 是否发送预览,此时 group_or_users 参数应为一个openid字符串 + + :return: 返回的 JSON 数据包 + """ + return self._send_mass_message( + group_or_users, + 'voice', + { + 'voice': { + 'media_id': media_id + } + }, + is_to_all, + preview + ) + + def send_mass_video(self, group_or_users, media_id, title=None, + description=None, is_to_all=False, preview=False): + """ + 群发视频消息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/5380a4e6f02f2ffdc7981a8ed7a40753.html + + :param group_or_users: 值为整型数字时为按分组群发,值为列表/元组时为按 OpenID 列表群发 + :param media_id: 视频的媒体 ID。可以通过 :func:`upload_video` 上传。 + :param title: 视频标题 + :param description: 视频描述 + :param is_to_all: 用于设定是否向全部用户发送,值为true或false,选择true该消息群发给所有用户 + 选择false可根据group_id发送给指定群组的用户 + :param preview: 是否发送预览,此时 group_or_users 参数应为一个openid字符串 + + :return: 返回的 JSON 数据包 + """ + video_data = { + 'media_id': media_id + } + if title: + video_data['title'] = title + if description: + video_data['description'] = description + return self._send_mass_message( + group_or_users, + 'mpvideo', + { + 'mpvideo': video_data + }, + is_to_all, + preview + ) + + def send_mass_article(self, group_or_users, media_id, + is_to_all=False, preview=False): + """ + 群发图文消息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/5380a4e6f02f2ffdc7981a8ed7a40753.html + + :param group_or_users: 值为整型数字时为按分组群发,值为列表/元组时为按 OpenID 列表群发 + :param media_id: 图文的媒体 ID。可以通过 :func:`upload_articles` 上传。 + :param is_to_all: 用于设定是否向全部用户发送,值为true或false,选择true该消息群发给所有用户 + 选择false可根据group_id发送给指定群组的用户 + :param preview: 是否发送预览,此时 group_or_users 参数应为一个openid字符串 + + :return: 返回的 JSON 数据包 + """ + return self._send_mass_message( + group_or_users, + 'mpnews', + { + 'mpnews': { + 'media_id': media_id + } + }, + is_to_all, + preview + ) + + def get_mass(self, msg_id): + """ + 查询群发消息发送状态 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/5380a4e6f02f2ffdc7981a8ed7a40753.html + + :param msg_id: 群发消息后返回的消息id + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.message.get_mass('mass message id') + + """ + return self._post( + 'message/mass/get', + data={ + 'msg_id': msg_id + } + ) + + def send_template(self, user_id, template_id, url, top_color, data): + """ + 发送模板消息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/17/304c1885ea66dbedf7dc170d84999a9d.html + + :param user_id: 用户 ID 。 就是你收到的 `Message` 的 source + :param template_id: 模板 ID。在公众平台线上模板库中选用模板获得 + :param url: 链接地址 + :param top_color: 消息顶部颜色 + :param data: 模板消息数据 + + :return: 返回的 JSON 数据包 + """ + return self._post( + 'message/template/send', + data={ + 'touser': user_id, + 'template_id': template_id, + 'url': url, + 'topcolor': top_color, + 'data': data + } + ) + + def send_template_applet(self, user_id, template_id, appid, pagepath, data): + """ + 发送模板消息,点击跳转到小程序 + + 详情请参考 + https://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1433751277 + + :param user_id: 用户 ID 。 就是你收到的 `Message` 的 source + :param template_id: 模板 ID。在公众平台线上模板库中选用模板获得 + :param appid: 所需跳转到的小程序appid(该小程序appid必须与发模板消息的公众号是绑定关联关系,暂不支持小游戏) + :param pagepath: 所需跳转到小程序的具体页面路径,支持带参数,(示例index?foo=bar),要求该小程序已发布,暂不支持小游戏 不必填 + :param data: 模板消息数据 + + :return: 返回的 JSON 数据包 + """ + data_all = { + 'touser': user_id, + 'template_id': template_id, + "miniprogram": { + "appid": appid, + # "pagepath": pagepath + }, + 'data': data + } + if pagepath: + data_all['miniprogram']['pagepath'] = pagepath + return self._post( + 'message/template/send', + data=data_all + ) + + def get_autoreply_info(self): + """ + 获取自动回复规则 + + 详情请参考 + http://mp.weixin.qq.com/wiki/7/7b5789bb1262fb866d01b4b40b0efecb.html + + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + info = client.message.get_autoreply_info() + + """ + return self._get('get_current_autoreply_info') + + def send_mass_card(self, group_or_users, card_id, + is_to_all=False, preview=False): + """ + 群发卡券消息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/5380a4e6f02f2ffdc7981a8ed7a40753.html + + :param group_or_users: 值为整型数字时为按分组群发,值为列表/元组时为按 OpenID 列表群发 + :param card_id: 卡券 ID + :param is_to_all: 用于设定是否向全部用户发送,值为true或false,选择true该消息群发给所有用户 + 选择false可根据group_id发送给指定群组的用户 + :param preview: 是否发送预览,此时 group_or_users 参数应为一个openid字符串 + + :return: 返回的 JSON 数据包 + """ + return self._send_mass_message( + group_or_users, + 'wxcard', + { + 'wxcard': { + 'card_id': card_id + } + }, + is_to_all, + preview + ) diff --git a/sg_wechat_enterprise/we_api/client/api/misc.py b/sg_wechat_enterprise/we_api/client/api/misc.py new file mode 100644 index 00000000..75623e10 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/misc.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatMisc(BaseWeChatAPI): + + def short_url(self, long_url): + """ + 将一条长链接转成短链接 + 详情请参考 + http://mp.weixin.qq.com/wiki/10/165c9b15eddcfbd8699ac12b0bd89ae6.html + + :param long_url: 长链接地址 + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.misc.short_url('http://www.qq.com') + + """ + return self._post( + 'shorturl', + data={ + 'action': 'long2short', + 'long_url': long_url + } + ) + + def get_wechat_ips(self): + """ + 获取微信服务器 IP 地址列表 + + :return: IP 地址列表 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + ips = client.misc.get_wechat_ips() + + """ + res = self._get( + 'getcallbackip', + result_processor=lambda x: x['ip_list'] + ) + return res diff --git a/sg_wechat_enterprise/we_api/client/api/poi.py b/sg_wechat_enterprise/we_api/client/api/poi.py new file mode 100644 index 00000000..a25ffc02 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/poi.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatPoi(BaseWeChatAPI): + + def add_picture(self, access_token, buffer): + """ + 上传图片接口 + :param access_token: 接口凭证 + :param buffer: buffer + :return: + 详情请参考: https://mp.weixin.qq.com/wiki/16/8f182af4d8dcea02c56506306bdb2f4c.html + """ + + def add(self, poi_data): + """ + 创建门店 + + 详情请参考 + http://mp.weixin.qq.com/wiki/16/8f182af4d8dcea02c56506306bdb2f4c.html + + :param poi_data: 门店信息字典 + :return: 返回的 JSON 数据包 + """ + return self._post('poi/addpoi', data=poi_data) + + def get(self, poi_id): + """ + 查询门店信息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/16/8f182af4d8dcea02c56506306bdb2f4c.html + + :param poi_id: 门店 ID + :return: 返回的 JSON 数据包 + """ + return self._post('poi/getpoi', data={'poi_id': poi_id}) + + def list(self, begin=0, limit=20): + """ + 查询门店列表 + + 详情请参考 + http://mp.weixin.qq.com/wiki/16/8f182af4d8dcea02c56506306bdb2f4c.html + + :param begin: 开始位置,0 即为从第一条开始查询 + :param limit: 返回数据条数,最大允许50,默认为20 + :return: 返回的 JSON 数据包 + """ + return self._post( + 'poi/getpoilist', + data={ + 'begin': begin, + 'limit': limit, + } + ) + + def update(self, poi_data): + """ + 修改门店 + + 详情请参考 + http://mp.weixin.qq.com/wiki/16/8f182af4d8dcea02c56506306bdb2f4c.html + + :param poi_data: 门店信息字典 + :return: 返回的 JSON 数据包 + """ + return self._post('poi/updatepoi', data=poi_data) + + def delete(self, poi_id): + """ + 删除门店 + + 详情请参考 + http://mp.weixin.qq.com/wiki/16/8f182af4d8dcea02c56506306bdb2f4c.html + + :param poi_id: 门店 ID + :return: 返回的 JSON 数据包 + """ + return self._post('poi/delpoi', data={'poi_id': poi_id}) + + def get_categories(self): + """ + 获取微信门店类目表 + + 详情请参考 + http://mp.weixin.qq.com/wiki/16/8f182af4d8dcea02c56506306bdb2f4c.html + + :return: 门店类目表 + """ + res = self._get( + 'api_getwxcategory', + result_processor=lambda x: x['category_list'] + ) + return res diff --git a/sg_wechat_enterprise/we_api/client/api/qrcode.py b/sg_wechat_enterprise/we_api/client/api/qrcode.py new file mode 100644 index 00000000..3edb7894 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/qrcode.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import requests +import six + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatQRCode(BaseWeChatAPI): + + def create(self, qrcode_data): + """ + 创建二维码 + 详情请参考 + http://mp.weixin.qq.com/wiki/18/28fc21e7ed87bec960651f0ce873ef8a.html + + :param data: 你要发送的参数 dict + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.qrcode.create({ + 'expire_seconds': 1800, + 'action_name': 'QR_SCENE', + 'action_info': { + 'scene': {'scene_id': 123}, + } + }) + + """ + return self._post( + 'qrcode/create', + data=qrcode_data + ) + + def show(self, ticket): + """ + 通过ticket换取二维码 + 详情请参考 + http://mp.weixin.qq.com/wiki/18/28fc21e7ed87bec960651f0ce873ef8a.html + + :param ticket: 二维码 ticket 。可以通过 :func:`create` 获取到 + :return: 返回的 Request 对象 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.qrcode.show('ticket data') + + """ + if isinstance(ticket, dict): + ticket = ticket['ticket'] + return requests.get( + url='https://mp.weixin.qq.com/cgi-bin/showqrcode', + params={ + 'ticket': ticket + } + ) + + @classmethod + def get_url(cls, ticket): + """ + 通过ticket换取二维码地址 + 详情请参考 + http://mp.weixin.qq.com/wiki/18/28fc21e7ed87bec960651f0ce873ef8a.html + + :param ticket: 二维码 ticket 。可以通过 :func:`create` 获取到 + :return: 返回的二维码地址 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + url = client.qrcode.get_url('ticket data') + + """ + url = 'https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket={ticket}' + if isinstance(ticket, dict): + ticket = ticket['ticket'] + ticket = six.moves.urllib.parse.quote(ticket) + return url.format(ticket=ticket) diff --git a/sg_wechat_enterprise/we_api/client/api/scan.py b/sg_wechat_enterprise/we_api/client/api/scan.py new file mode 100644 index 00000000..6bbf80f6 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/scan.py @@ -0,0 +1,171 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from optionaldict import optionaldict + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatScan(BaseWeChatAPI): + API_BASE_URL = 'https://api.weixin.qq.com/scan/' + + def get_merchant_info(self): + """ + 获取商户信息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/6/c61604ff6890d386d6227945ad4a68d2.html + + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + info = client.scan.get_merchant_info() + """ + return self._get('merchantinfo/get') + + def create_product(self, product_data): + """ + 创建商品 + + 详情请参考 + http://mp.weixin.qq.com/wiki/6/c61604ff6890d386d6227945ad4a68d2.html + + :return: 返回的 JSON 数据包 + """ + return self._post('product/create', data=product_data) + + def modify_product_status(self, standard, key, status): + """ + 提交审核/取消发布商品 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/1007691d0f1c10a0588c6517f12ed70f.html + + :param standard: 商品编码标准 + :param key: 商品编码内容 + :param status: 设置发布状态。on 为提交审核,off 为取消发布 + :return: 返回的 JSON 数据包 + """ + data = { + 'keystandard': standard, + 'keystr': key, + 'status': status, + } + return self._post('product/modstatus', data=data) + + def publish_product(self, standard, key): + """ + 提交审核商品 shortcut 接口 + + 等同于调用 ``modify_product_status(standard, key, 'on')`` + """ + return self.modify_product_status(standard, key, 'on') + + def unpublish_product(self, standard, key): + """ + 取消发布商品 shortcut 接口 + + 等同于调用 ``modify_product_status(standard, key, 'off')`` + """ + return self.modify_product_status(standard, key, 'off') + + def set_test_whitelist(self, userids=None, usernames=None): + """ + 设置测试人员白名单 + + 注意:每次设置均被视为一次重置,而非增量设置。openid、微信号合计最多设置10个。 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/1007691d0f1c10a0588c6517f12ed70f.html + + :param userids: 可选,测试人员的 openid 列表 + :param usernames: 可选,测试人员的微信号列表 + :return: 返回的 JSON 数据包 + """ + data = optionaldict( + openid=userids, + username=usernames + ) + return self._post('testwhitelist/set', data=data) + + def get_product(self, standard, key): + """ + 查询商品信息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/7fa787701295b884410b5163e13313af.html + + :param standard: 商品编码标准 + :param key: 商品编码内容 + :return: 返回的 JSON 数据包 + """ + data = { + 'keystandard': standard, + 'keystr': key, + } + return self._post('product/get', data=data) + + def list_product(self, offset=0, limit=10, status=None, key=None): + """ + 批量查询商品信息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/7fa787701295b884410b5163e13313af.html + + :param offset: 可选,批量查询的起始位置,从 0 开始,包含该起始位置 + :param limit: 可选,批量查询的数量,默认为 10 + :param status: 可选,支持按状态拉取。on为发布状态,off为未发布状态, + check为审核中状态,reject为审核未通过状态,all为所有状态 + :param key: 支持按部分编码内容拉取。填写该参数后,可将编码内容中包含所传参数的商品信息拉出 + :return: 返回的 JSON 数据包 + """ + data = optionaldict( + offset=offset, + limit=limit, + status=status, + keystr=key, + ) + return self._post('product/getlist', data=data) + + def update_product(self, product_data): + """ + 更新商品信息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/7fa787701295b884410b5163e13313af.html + + :return: 返回的 JSON 数据包 + """ + return self._post('product/update', data=product_data) + + def clear_product(self, standard, key): + """ + 清除商品信息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/7fa787701295b884410b5163e13313af.html + + :param standard: 商品编码标准 + :param key: 商品编码内容 + :return: 返回的 JSON 数据包 + """ + data = { + 'keystandard': standard, + 'keystr': key, + } + return self._post('product/clear', data=data) + + def check_ticket(self, ticket): + """ + 检查 wxticket 参数有效性 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/7fa787701295b884410b5163e13313af.html + + :param ticket: 请求 URL 中带上的 wxticket 参数 + :return: 返回的 JSON 数据包 + """ + return self._post('scanticket/check', data={'ticket': ticket}) diff --git a/sg_wechat_enterprise/we_api/client/api/semantic.py b/sg_wechat_enterprise/we_api/client/api/semantic.py new file mode 100644 index 00000000..6dc61252 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/semantic.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from optionaldict import optionaldict + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatSemantic(BaseWeChatAPI): + + def search(self, + query, + category, + uid=None, + latitude=None, + longitude=None, + city=None, + region=None): + """ + 发送语义理解请求 + 详情请参考 + http://mp.weixin.qq.com/wiki/0/0ce78b3c9524811fee34aba3e33f3448.html + + :param query: 输入文本串 + :param category: 需要使用的服务类型,多个可传入列表 + :param uid: 可选,用户唯一id(非开发者id),用户区分公众号下的不同用户(建议填入用户openid) + :param latitude: 可选,纬度坐标,与经度同时传入;与城市二选一传入 + :param longitude: 可选,经度坐标,与纬度同时传入;与城市二选一传入 + :param city: 可选,城市名称,与经纬度二选一传入 + :param region: 可选,区域名称,在城市存在的情况下可省;与经纬度二选一传入 + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + res = client.semantic.search( + '查一下明天从北京到上海的南航机票', + 'flight,hotel', + city='北京' + ) + + """ + if isinstance(category, (tuple, list)): + category = ','.join(category) + + data = optionaldict() + data['query'] = query + data['category'] = category + data['uid'] = uid + data['latitude'] = latitude + data['longitude'] = longitude + data['city'] = city + data['region'] = region + data['appid'] = self._client.appid + return self._post( + url='https://api.weixin.qq.com/semantic/semproxy/search', + data=data + ) diff --git a/sg_wechat_enterprise/we_api/client/api/shakearound.py b/sg_wechat_enterprise/we_api/client/api/shakearound.py new file mode 100644 index 00000000..6f63dd9a --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/shakearound.py @@ -0,0 +1,376 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import time +from datetime import datetime + +import six +from optionaldict import optionaldict + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatShakeAround(BaseWeChatAPI): + + API_BASE_URL = 'https://api.weixin.qq.com/' + + @classmethod + def _to_timestamp(cls, date): + if isinstance(date, six.string_types): + date = datetime.strptime(date, '%Y-%m-%d %H:%M:%S') + if isinstance(date, datetime): + timestamp = int(time.mktime(date.timetuple())) + return timestamp + return int(date) + + def apply_device_id(self, quantity, reason, poi_id=None, comment=None): + """ + 申请设备ID + 详情请参考 + http://mp.weixin.qq.com/wiki/15/b9e012f917e3484b7ed02771156411f3.html + + :param quantity: 申请的设备ID的数量,单次新增设备超过500个,需走人工审核流程 + :param reason: 申请理由,不超过100个字 + :param poi_id: 可选,设备关联的门店ID + :param comment: 可选,备注,不超过15个汉字或30个英文字母 + :return: 申请的设备信息 + """ + data = optionaldict() + data['quantity'] = quantity + data['apply_reason'] = reason + data['poi_id'] = poi_id + data['comment'] = comment + res = self._post( + 'shakearound/device/applyid', + data=data, + result_processor=lambda x: x['data'] + ) + return res + + def update_device(self, device_id=None, uuid=None, major=None, + minor=None, comment=None): + """ + 更新设备信息 + 详情请参考 + http://mp.weixin.qq.com/wiki/15/b9e012f917e3484b7ed02771156411f3.html + + :param device_id: 设备编号,若填了UUID、major、minor,则可不填设备编号,若二者都填,则以设备编号为优先 + :param uuid: UUID + :param major: major + :param minor: minor + :param comment: 设备的备注信息,不超过15个汉字或30个英文字母。 + :return: 返回的 JSON 数据包 + """ + data = optionaldict() + data['comment'] = comment + data['device_identifier'] = { + 'device_id': device_id, + 'uuid': uuid, + 'major': major, + 'minor': minor + } + return self._post( + 'shakearound/device/update', + data=data + ) + + def bind_device_location(self, poi_id, device_id=None, uuid=None, + major=None, minor=None): + """ + 配置设备与门店的关联关系 + 详情请参考 + http://mp.weixin.qq.com/wiki/15/b9e012f917e3484b7ed02771156411f3.html + + :param poi_id: 待关联的门店ID + :param device_id: 设备编号,若填了UUID、major、minor,则可不填设备编号,若二者都填,则以设备编号为优先 + :param uuid: UUID + :param major: major + :param minor: minor + :return: 返回的 JSON 数据包 + """ + data = optionaldict() + data['poi_id'] = poi_id + data['device_identifier'] = { + 'device_id': device_id, + 'uuid': uuid, + 'major': major, + 'minor': minor + } + return self._post( + 'shakearound/device/bindlocation', + data=data + ) + + def search_device(self, identifiers=None, apply_id=None, + begin=0, count=10): + """ + 查询设备列表 + 详情请参考 + http://mp.weixin.qq.com/wiki/15/b9e012f917e3484b7ed02771156411f3.html + + :param identifiers: 设备 ID 信息列表 + :param apply_id: 批次ID,申请设备ID超出500个时所返回批次ID + :param begin: 设备列表的起始索引值 + :param count: 待查询的设备个数 + :return: 设备列表 + """ + data = optionaldict() + data['begin'] = begin + data['count'] = count + data['apply_id'] = apply_id + if identifiers: + data['device_identifiers'] = identifiers + res = self._post( + 'shakearound/device/search', + data=data, + result_processor=lambda x: x['data'] + ) + return res + + def add_page(self, title, description, icon_url, page_url, comment=None): + """ + 新增页面 + 详情请参考 + http://mp.weixin.qq.com/wiki/5/6626199ea8757c752046d8e46cf13251.html + + :param title: 在摇一摇页面展示的主标题,不超过6个字 + :param description: 在摇一摇页面展示的副标题,不超过7个字 + :param icon_url: 在摇一摇页面展示的图片。图片需先上传至微信侧服务器, + 用“素材管理-上传图片素材”接口上传图片,返回的图片URL再配置在此处 + :param page_url: 跳转链接 + :param comment: 可选,页面的备注信息,不超过15个字 + :return: 页面信息 + """ + data = optionaldict() + data['title'] = title + data['description'] = description + data['icon_url'] = icon_url + data['page_url'] = page_url + data['comment'] = comment + res = self._post( + 'shakearound/page/add', + data=data, + result_processor=lambda x: x['data'] + ) + return res + + def update_page(self, page_id, title, description, + icon_url, page_url, comment=None): + """ + 编辑页面信息 + 详情请参考 + http://mp.weixin.qq.com/wiki/5/6626199ea8757c752046d8e46cf13251.html + + :param page_id: 摇周边页面唯一ID + :param title: 在摇一摇页面展示的主标题,不超过6个字 + :param description: 在摇一摇页面展示的副标题,不超过7个字 + :param icon_url: 在摇一摇页面展示的图片。图片需先上传至微信侧服务器, + 用“素材管理-上传图片素材”接口上传图片,返回的图片URL再配置在此处 + :param page_url: 跳转链接 + :param comment: 可选,页面的备注信息,不超过15个字 + :return: 页面信息 + """ + data = optionaldict() + data['page_id'] = page_id + data['title'] = title + data['description'] = description + data['icon_url'] = icon_url + data['page_url'] = page_url + data['comment'] = comment + res = self._post( + 'shakearound/page/update', + data=data, + result_processor=lambda x: x['data'] + ) + return res + + def search_pages(self, page_ids=None, begin=0, count=10): + """ + 查询页面列表 + 详情请参考 + http://mp.weixin.qq.com/wiki/5/6626199ea8757c752046d8e46cf13251.html + + :param page_ids: 指定页面的id列表 + :param begin: 页面列表的起始索引值 + :param count: 待查询的页面个数 + :return: 页面查询结果信息 + """ + if not page_ids: + data = { + 'type': 2, + 'begin': begin, + 'count': count + } + else: + if not isinstance(page_ids, (tuple, list)): + page_ids = [page_ids] + data = { + 'type': 1, + 'page_ids': page_ids + } + + res = self._post( + 'shakearound/page/search', + data=data, + result_processor=lambda x: x['data'] + ) + return res + + def delete_page(self, page_id): + """ + 删除页面 + 详情请参考 + http://mp.weixin.qq.com/wiki/5/6626199ea8757c752046d8e46cf13251.html + + :param page_id: 指定页面的id列表 + :return: 返回的 JSON 数据包 + """ + return self._post( + 'shakearound/page/delete', + data={ + 'page_id': page_id + } + ) + + def add_material(self, media_file, media_type='icon'): + """ + 上传图片素材 + 详情请参考 + http://mp.weixin.qq.com/wiki/5/e997428269ff189d8f9a4b9e177be2d9.html + + :param media_file: 要上传的文件,一个 File-object + :param media_type: 摇一摇素材类型, 取值为 icon或者 license, 默认 icon. + :return: 上传的素材信息 + """ + res = self._post( + 'shakearound/material/add', + files={ + 'media': media_file + }, + params={ + 'type': media_type + }, + result_processor=lambda x: x['data'] + ) + return res + + def bind_device_pages(self, page_ids, bind, append, device_id=None, + uuid=None, major=None, minor=None): + """ + 配置设备与页面的关联关系 + 详情请参考 + http://mp.weixin.qq.com/wiki/12/c8120214ec0ba08af5dfcc0da1a11400.html + + :param page_ids: 待关联的页面列表 + :param bind: 关联操作标志位, 0为解除关联关系,1为建立关联关系 + :param append: 新增操作标志位, 0为覆盖,1为新增 + :param device_id: 设备编号,若填了UUID、major、minor,则可不填设备编号,若二者都填,则以设备编号为优先 + :param uuid: UUID + :param major: major + :param minor: minor + :return: 返回的 JSON 数据包 + """ + if not isinstance(page_ids, (tuple, list)): + page_ids = [page_ids] + data = { + 'page_ids': page_ids, + 'bind': int(bind), + 'append': int(append), + 'device_identifier': { + 'device_id': device_id, + 'uuid': uuid, + 'major': major, + 'minor': minor + } + } + return self._post( + 'shakearound/device/bindpage', + data=data + ) + + def get_shake_info(self, ticket): + """ + 获取摇周边的设备及用户信息 + 详情请参考 + http://mp.weixin.qq.com/wiki/3/34904a5db3d0ec7bb5306335b8da1faf.html + + :param ticket: 摇周边业务的ticket,可在摇到的URL中得到,ticket生效时间为30分钟 + :return: 设备及用户信息 + """ + res = self._post( + 'shakearound/user/getshakeinfo', + data={ + 'ticket': ticket + }, + result_processor=lambda x: x['data'] + ) + return res + + def get_device_statistics(self, begin_date, end_date, device_id=None, + uuid=None, major=None, minor=None): + """ + 以设备为维度的数据统计接口 + http://mp.weixin.qq.com/wiki/0/8a24bcacad40fe7ee98d1573cb8a6764.html + + :param begin_date: 起始时间,最长时间跨度为30天 + :param end_date: 结束时间,最长时间跨度为30天 + :param device_id: 设备编号,若填了UUID、major、minor,则可不填设备编号,若二者都填,则以设备编号为优先 + :param uuid: UUID + :param major: major + :param minor: minor + """ + data = { + 'device_identifier': { + 'device_id': device_id, + 'uuid': uuid, + 'major': major, + 'minor': minor + }, + 'begin_date': self._to_timestamp(begin_date), + 'end_date': self._to_timestamp(end_date) + } + res = self._post( + 'shakearound/statistics/device', + data=data, + result_processor=lambda x: x['data'] + ) + return res + + def get_page_statistics(self, page_id, begin_date, end_date): + """ + 以页面为维度的数据统计接口 + 详情请参考 + http://mp.weixin.qq.com/wiki/0/8a24bcacad40fe7ee98d1573cb8a6764.html + + :param page_id: 页面 ID + :param begin_date: 起始时间,最长时间跨度为30天 + :param end_date: 结束时间,最长时间跨度为30天 + :return: 统计数据 + """ + res = self._post( + 'shakearound/statistics/page', + data={ + 'page_id': page_id, + 'begin_date': self._to_timestamp(begin_date), + 'end_date': self._to_timestamp(end_date), + }, + result_processor=lambda x: x['data'] + ) + return res + + def get_apply_status(self, apply_id): + """ + 查询设备ID申请审核状态 + 详情请参考 + http://mp.weixin.qq.com/wiki/15/b9e012f917e3484b7ed02771156411f3.html + + :param apply_id: 批次ID,申请设备ID时所返回的批次ID + :return: 批次状态信息 + """ + res = self._post( + 'shakearound/device/applystatus', + data={ + 'apply_id': apply_id, + }, + result_processor=lambda x: x['data'] + ) + return res diff --git a/sg_wechat_enterprise/we_api/client/api/template.py b/sg_wechat_enterprise/we_api/client/api/template.py new file mode 100644 index 00000000..46daa41b --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/template.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatTemplate(BaseWeChatAPI): + + def set_industry(self, industry_id1, industry_id2): + """ + 设置所属行业 + 详情请参考 + http://mp.weixin.qq.com/wiki/17/304c1885ea66dbedf7dc170d84999a9d.html + + :param industry_id1: 公众号模板消息所属行业编号 + :param industry_id2: 公众号模板消息所属行业编号 + :return: 返回的 JSON 数据包 + """ + return self._post( + 'template/api_set_industry', + data={ + 'industry_id1': industry_id1, + 'industry_id2': industry_id2 + } + ) + + def get(self, template_id_short): + """ + 获得模板ID + 详情请参考 + http://mp.weixin.qq.com/wiki/17/304c1885ea66dbedf7dc170d84999a9d.html + + :param template_id_short: 模板库中模板的编号,有“TM**”和“OPENTMTM**”等形式 + :return: 模板 ID + """ + res = self._post( + 'template/api_add_template', + data={ + 'template_id_short': template_id_short + }, + result_processor=lambda x: x['template_id'] + ) + return res + + add = get diff --git a/sg_wechat_enterprise/we_api/client/api/user.py b/sg_wechat_enterprise/we_api/client/api/user.py new file mode 100644 index 00000000..320c905e --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/user.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import six + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatUser(BaseWeChatAPI): + + def get(self, user_id, lang='zh_CN'): + """ + 获取用户基本信息 + 详情请参考 + http://mp.weixin.qq.com/wiki/14/bb5031008f1494a59c6f71fa0f319c66.html + + :param user_id: 用户 ID 。 就是你收到的 `Message` 的 source + :param lang: 返回国家地区语言版本,zh_CN 简体,zh_TW 繁体,en 英语 + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + user = client.user.get('openid') + + """ + assert lang in ('zh_CN', 'zh_TW', 'en'), 'lang can only be one of \ + zh_CN, zh_TW, en language codes' + return self._get( + 'user/info', + params={ + 'openid': user_id, + 'lang': lang + } + ) + + def get_followers(self, first_user_id=None): + """ + 获取关注者列表 + 详情请参考 + http://mp.weixin.qq.com/wiki/3/17e6919a39c1c53555185907acf70093.html + + :param first_user_id: 可选。第一个拉取的 OPENID,不填默认从头开始拉取 + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + followers = client.user.get_followers() + + """ + params = {} + if first_user_id: + params['next_openid'] = first_user_id + return self._get( + 'user/get', + params=params + ) + + def update_remark(self, user_id, remark): + """ + 设置用户备注名 + 详情请参考 + http://mp.weixin.qq.com/wiki/10/bf8f4e3074e1cf91eb6518b6d08d223e.html + + :param user_id: 用户 ID 。 就是你收到的 `Message` 的 source + :param remark: 备注名 + :return: 返回的 JSON 数据包 + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + client.user.update_remark('openid', 'Remark') + + """ + return self._post( + 'user/info/updateremark', + data={ + 'openid': user_id, + 'remark': remark + } + ) + + def get_group_id(self, user_id): + """ + 获取用户所在分组 ID + + 详情请参考 + http://mp.weixin.qq.com/wiki/0/56d992c605a97245eb7e617854b169fc.html + + :param user_id: 用户 ID + :return: 用户所在分组 ID + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + group_id = client.user.get_group_id('openid') + + """ + res = self._post( + 'groups/getid', + data={'openid': user_id}, + result_processor=lambda x: x['groupid'] + ) + return res + + def get_batch(self, user_list): + """ + 批量获取用户基本信息 + + 详情请参考 + http://mp.weixin.qq.com/wiki/14/bb5031008f1494a59c6f71fa0f319c66.html#.E6.89.B9.E9.87.8F.E8.8E.B7.E5.8F.96.E7.94.A8.E6.88.B7.E5.9F.BA.E6.9C.AC.E4.BF.A1.E6.81.AF + + :param user_id: user_list + :return: 用户信息的 list + + 使用示例:: + + from wechatpy import WeChatClient + + client = WeChatClient('appid', 'secret') + users = client.user.get_batch(['openid1', 'openid2']) + users = client.user.get_batch([ + {'openid': 'openid1', 'lang': 'zh-CN'}, + {'openid': 'openid2', 'lang': 'en'}, + ]) + + """ + if all((isinstance(x, six.string_types) for x in user_list)): + user_list = [{'openid': oid} for oid in user_list] + res = self._post( + 'user/info/batchget', + data={'user_list': user_list}, + result_processor=lambda x: x['user_info_list'] + ) + return res diff --git a/sg_wechat_enterprise/we_api/client/api/wifi.py b/sg_wechat_enterprise/we_api/client/api/wifi.py new file mode 100644 index 00000000..1e8149e4 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/api/wifi.py @@ -0,0 +1,195 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from datetime import datetime, date + +from optionaldict import optionaldict +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatWiFi(BaseWeChatAPI): + + API_BASE_URL = 'https://api.weixin.qq.com/bizwifi/' + + def list_shops(self, page_index=1, page_size=20): + """ + 获取门店列表 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/bcfb5d4578ea818b89913472cf2bbf8f.html + + :param page_index: 可选,分页下标,默认从1开始 + :param page_size: 可选,每页的个数,默认20个,最大20个 + :return: 返回的 JSON 数据包 + """ + res = self._post( + 'shop/list', + data={ + 'pageindex': page_index, + 'pagesize': page_size, + }, + result_processor=lambda x: x['data'] + ) + return res + + def get_shop(self, shop_id=0): + """ + 查询门店的WiFi信息 + http://mp.weixin.qq.com/wiki/15/bcfb5d4578ea818b89913472cf2bbf8f.html + + :param shop_id: 门店 ID + :return: 返回的 JSON 数据包 + """ + res = self._post( + 'shop/get', + data={ + 'shop_id': shop_id, + }, + result_processor=lambda x: x['data'] + ) + return res + + def add_device(self, shop_id, ssid, password, bssid): + """ + 添加设备 + + 详情请参考 + http://mp.weixin.qq.com/wiki/10/6232005bdc497f7cf8e19d4e843c70d2.html + + :param shop_id: 门店 ID + :param ssid: 无线网络设备的ssid。非认证公众号添加的ssid必需是“WX”开头(“WX”为大写字母), + 认证公众号和第三方平台无此限制;所有ssid均不能包含中文字符 + :param password: 无线网络设备的密码,大于8个字符,不能包含中文字符 + :param bssid: 无线网络设备无线mac地址,格式冒号分隔,字符长度17个,并且字母小写 + :return: 返回的 JSON 数据包 + """ + return self._post( + 'device/add', + data={ + 'shop_id': shop_id, + 'ssid': ssid, + 'password': password, + 'bssid': bssid, + } + ) + + def list_devices(self, shop_id=None, page_index=1, page_size=20): + """ + 查询设备 + + 详情请参考 + http://mp.weixin.qq.com/wiki/10/6232005bdc497f7cf8e19d4e843c70d2.html + + :param shop_id: 可选,门店 ID + :param page_index: 可选,分页下标,默认从1开始 + :param page_size: 可选,每页的个数,默认20个,最大20个 + :return: 返回的 JSON 数据包 + """ + data = optionaldict( + shop_id=shop_id, + pageindex=page_index, + pagesize=page_size + ) + res = self._post( + 'device/list', + data=data, + result_processor=lambda x: x['data'] + ) + return res + + def delete_device(self, bssid): + """ + 删除设备 + + 详情请参考 + http://mp.weixin.qq.com/wiki/10/6232005bdc497f7cf8e19d4e843c70d2.html + + :param bssid: 无线网络设备无线mac地址,格式冒号分隔,字符长度17个,并且字母小写 + :return: 返回的 JSON 数据包 + """ + return self._post('device/delete', data={'bssid': bssid}) + + def get_qrcode_url(self, shop_id, img_id): + """ + 获取物料二维码图片网址 + + 详情请参考 + http://mp.weixin.qq.com/wiki/7/fcd0378ef00617fc276be2b3baa80973.html + + :param shop_id: 门店 ID + :param img_id: 物料样式编号:0-二维码,可用于自由设计宣传材料; + 1-桌贴(二维码),100mm×100mm(宽×高),可直接张贴 + :return: 二维码图片网址 + """ + res = self._post( + 'qrcode/get', + data={ + 'shop_id': shop_id, + 'img_id': img_id, + }, + result_processor=lambda x: x['data']['qrcode_url'] + ) + return res + + def set_homepage(self, shop_id, template_id, url=None): + """ + 设置商家主页 + + 详情请参考 + http://mp.weixin.qq.com/wiki/6/2732f3cf83947e0e4971aa8797ee9d6a.html + + :param shop_id: 门店 ID + :param template_id: 模板ID,0-默认模板,1-自定义url + :param url: 自定义链接,当template_id为1时必填 + :return: 返回的 JSON 数据包 + """ + data = { + 'shop_id': shop_id, + 'template_id': template_id, + } + if url: + data['struct'] = {'url': url} + return self._post('homepage/set', data=data) + + def get_homepage(self, shop_id): + """ + 查询商家主页 + + 详情请参考 + http://mp.weixin.qq.com/wiki/6/2732f3cf83947e0e4971aa8797ee9d6a.html + + :param shop_id: 门店 ID + :return: 返回的 JSON 数据包 + """ + res = self._post( + 'homepage/get', + data={'shop_id': shop_id}, + result_processor=lambda x: x['data'] + ) + return res + + def list_statistics(self, begin_date, end_date, shop_id=-1): + """ + Wi-Fi数据统计 + + 详情请参考 + http://mp.weixin.qq.com/wiki/8/dfa2b756b66fca5d9b1211bc18812698.html + + :param begin_date: 起始日期时间,最长时间跨度为30天 + :param end_date: 结束日期时间戳,最长时间跨度为30天 + :param shop_id: 可选,门店 ID,按门店ID搜索,-1为总统计 + :return: 返回的 JSON 数据包 + """ + if isinstance(begin_date, (datetime, date)): + begin_date = begin_date.strftime('%Y-%m-%d') + if isinstance(end_date, (datetime, date)): + end_date = end_date.strftime('%Y-%m-%d') + res = self._post( + 'statistics/list', + data={ + 'begin_date': begin_date, + 'end_date': end_date, + 'shop_id': shop_id + }, + result_processor=lambda x: x['data'] + ) + return res diff --git a/sg_wechat_enterprise/we_api/client/base.py b/sg_wechat_enterprise/we_api/client/base.py new file mode 100644 index 00000000..3a8edc49 --- /dev/null +++ b/sg_wechat_enterprise/we_api/client/base.py @@ -0,0 +1,251 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import sys +import time +import inspect +import logging + +import six +import requests +from wechatpy.utils import json, get_querystring +from wechatpy.session.memorystorage import MemoryStorage +from wechatpy.exceptions import WeChatClientException, APILimitedException +from wechatpy.client.api.base import BaseWeChatAPI + + +logger = logging.getLogger(__name__) + + +def _is_api_endpoint(obj): + return isinstance(obj, BaseWeChatAPI) + + +class BaseWeChatClient(object): + + API_BASE_URL = '' + + def __new__(cls, *args, **kwargs): + self = super(BaseWeChatClient, cls).__new__(cls) + if sys.version_info[:2] == (2, 6): + # Python 2.6 inspect.gemembers bug workaround + # http://bugs.python.org/issue1785 + for _class in cls.__mro__: + if issubclass(_class, BaseWeChatClient): + for name, api in _class.__dict__.items(): + if isinstance(api, BaseWeChatAPI): + api_cls = type(api) + api = api_cls(self) + setattr(self, name, api) + else: + api_endpoints = inspect.getmembers(self, _is_api_endpoint) + for name, api in api_endpoints: + api_cls = type(api) + api = api_cls(self) + setattr(self, name, api) + return self + + def __init__(self, appid, access_token=None, session=None, timeout=None, auto_retry=True): + self.appid = appid + self.expires_at = None + self.session = session or MemoryStorage() + self.timeout = timeout + self.auto_retry = auto_retry + + if isinstance(session, six.string_types): + from shove import Shove + from wechatpy.session.shovestorage import ShoveStorage + + querystring = get_querystring(session) + prefix = querystring.get('prefix', ['wechatpy'])[0] + + shove = Shove(session) + storage = ShoveStorage(shove, prefix) + self.session = storage + + if access_token: + self.session.set(self.access_token_key, access_token) + + @property + def access_token_key(self): + return '{0}_access_token'.format(self.appid) + + def _request(self, method, url_or_endpoint, **kwargs): + if not url_or_endpoint.startswith(('http://', 'https://')): + api_base_url = kwargs.pop('api_base_url', self.API_BASE_URL) + url = '{base}{endpoint}'.format( + base=api_base_url, + endpoint=url_or_endpoint + ) + else: + url = url_or_endpoint + + # 群发消息上传视频接口地址 HTTPS 证书错误,暂时忽略证书验证 + if url.startswith('https://file.api.weixin.qq.com'): + kwargs['verify'] = False + + if 'params' not in kwargs: + kwargs['params'] = {} + if isinstance(kwargs['params'], dict) and \ + 'access_token' not in kwargs['params']: + kwargs['params']['access_token'] = self.access_token + if isinstance(kwargs.get('data', ''), dict): + body = json.dumps(kwargs['data'], ensure_ascii=False) + body = body.encode('utf-8') + kwargs['data'] = body + + kwargs['timeout'] = kwargs.get('timeout', self.timeout) + result_processor = kwargs.pop('result_processor', None) + res = requests.request( + method=method, + url=url, + **kwargs + ) + try: + res.raise_for_status() + except requests.RequestException as reqe: + raise WeChatClientException( + errcode=None, + errmsg=None, + client=self, + request=reqe.request, + response=reqe.response + ) + + return self._handle_result( + res, method, url, result_processor, **kwargs + ) + + def _decode_result(self, res): + try: + result = json.loads(res.content.decode('utf-8', 'ignore'), strict=False) + except (TypeError, ValueError): + # Return origin response object if we can not decode it as JSON + logger.debug('Can not decode response as JSON', exc_info=True) + return res + return result + + def _handle_result(self, res, method=None, url=None, + result_processor=None, **kwargs): + if not isinstance(res, dict): + # Dirty hack around asyncio based AsyncWeChatClient + result = self._decode_result(res) + else: + result = res + + if not isinstance(result, dict): + return result + + if 'base_resp' in result: + # Different response in device APIs. Fuck tencent! + result = result['base_resp'] + if 'errcode' in result: + result['errcode'] = int(result['errcode']) + + if 'errcode' in result and result['errcode'] != 0: + errcode = result['errcode'] + errmsg = result.get('errmsg', errcode) + if errcode in (40001, 40014, 42001) and self.auto_retry: + logger.info('Access token expired, fetch a new one and retry request') + self.fetch_access_token() + access_token = self.session.get(self.access_token_key) + kwargs['params']['access_token'] = access_token + return self._request( + method=method, + url_or_endpoint=url, + result_processor=result_processor, + **kwargs + ) + elif errcode == 45009: + # api freq out of limit + raise APILimitedException( + errcode, + errmsg, + client=self, + request=res.request, + response=res + ) + else: + raise WeChatClientException( + errcode, + errmsg, + client=self, + request=res.request, + response=res + ) + + return result if not result_processor else result_processor(result) + + def get(self, url, **kwargs): + return self._request( + method='get', + url_or_endpoint=url, + **kwargs + ) + + _get = get + + def post(self, url, **kwargs): + return self._request( + method='post', + url_or_endpoint=url, + **kwargs + ) + + _post = post + + def _fetch_access_token(self, url, params): + """ The real fetch access token """ + logger.info('Fetching access token') + res = requests.get( + url=url, + params=params + ) + try: + res.raise_for_status() + except requests.RequestException as reqe: + raise WeChatClientException( + errcode=None, + errmsg=None, + client=self, + request=reqe.request, + response=reqe.response + ) + result = res.json() + if 'errcode' in result and result['errcode'] != 0: + raise WeChatClientException( + result['errcode'], + result['errmsg'], + client=self, + request=res.request, + response=res + ) + + expires_in = 7200 + if 'expires_in' in result: + expires_in = result['expires_in'] + self.session.set( + self.access_token_key, + result['access_token'], + expires_in + ) + self.expires_at = int(time.time()) + expires_in + return result + + def fetch_access_token(self): + raise NotImplementedError() + + @property + def access_token(self): + """ WeChat access token """ + access_token = self.session.get(self.access_token_key) + if access_token: + if not self.expires_at: + # user provided access_token, just return it + return access_token + + timestamp = time.time() + if self.expires_at - timestamp > 60: + return access_token + + self.fetch_access_token() + return self.session.get(self.access_token_key) diff --git a/sg_wechat_enterprise/we_api/component.py b/sg_wechat_enterprise/we_api/component.py new file mode 100644 index 00000000..79792961 --- /dev/null +++ b/sg_wechat_enterprise/we_api/component.py @@ -0,0 +1,431 @@ +# -*- coding: utf-8 -*- +""" + wechatpy.component + ~~~~~~~~~~~~~~~ + + This module provides client library for WeChat Open Platform + + :copyright: (c) 2015 by hunter007. + :license: MIT, see LICENSE for more details. +""" +from __future__ import absolute_import, unicode_literals +import time +import logging + +import six +import requests +import xmltodict + +from wechatpy.utils import to_text, to_binary, get_querystring, json +from wechatpy.fields import StringField, DateTimeField +from wechatpy.messages import MessageMetaClass +from wechatpy.session.memorystorage import MemoryStorage +from wechatpy.exceptions import WeChatClientException, APILimitedException +from wechatpy.crypto import WeChatCrypto +from wechatpy.client import WeChatComponentClient + + +logger = logging.getLogger(__name__) + + +class BaseComponentMessage(six.with_metaclass(MessageMetaClass)): + """Base class for all component messages and events""" + type = 'unknown' + appid = StringField('AppId') + create_time = DateTimeField('CreateTime') + + def __init__(self, message): + self._data = message + + def __repr__(self): + _repr = "{klass}({msg})".format( + klass=self.__class__.__name__, + msg=repr(self._data) + ) + if six.PY2: + return to_binary(_repr) + else: + return to_text(_repr) + + +class ComponentVerifyTicketMessage(BaseComponentMessage): + """ + component_verify_ticket协议 + """ + type = 'component_verify_ticket' + verify_ticket = StringField('ComponentVerifyTicket') + + +class ComponentUnauthorizedMessage(BaseComponentMessage): + """ + 取消授权通知 + """ + type = 'unauthorized' + authorizer_appid = StringField('AuthorizerAppid') + + +class BaseWeChatComponent(object): + + API_BASE_URL = 'https://api.weixin.qq.com/cgi-bin' + + def __init__(self, + component_appid, + component_appsecret, + component_token, + encoding_aes_key, + session=None): + """ + :param component_appid: 第三方平台appid + :param component_appsecret: 第三方平台appsecret + :param component_token: 公众号消息校验Token + :param encoding_aes_key: 公众号消息加解密Key + """ + self.component_appid = component_appid + self.component_appsecret = component_appsecret + self.expires_at = None + self.crypto = WeChatCrypto( + component_token, encoding_aes_key, component_appid) + self.session = session or MemoryStorage() + + if isinstance(session, six.string_types): + from shove import Shove + from wechatpy.session.shovestorage import ShoveStorage + + querystring = get_querystring(session) + prefix = querystring.get('prefix', ['wechatpy'])[0] + + shove = Shove(session) + storage = ShoveStorage(shove, prefix) + self.session = storage + + @property + def component_verify_ticket(self): + return self.session.get('component_verify_ticket') + + def _request(self, method, url_or_endpoint, **kwargs): + if not url_or_endpoint.startswith(('http://', 'https://')): + api_base_url = kwargs.pop('api_base_url', self.API_BASE_URL) + url = '{base}{endpoint}'.format( + base=api_base_url, + endpoint=url_or_endpoint + ) + else: + url = url_or_endpoint + + if 'params' not in kwargs: + kwargs['params'] = {} + if isinstance(kwargs['params'], dict) and \ + 'component_access_token' not in kwargs['params']: + kwargs['params'][ + 'component_access_token'] = self.access_token + if isinstance(kwargs['data'], dict): + kwargs['data'] = json.dumps(kwargs['data']) + + res = requests.request( + method=method, + url=url, + **kwargs + ) + try: + res.raise_for_status() + except requests.RequestException as reqe: + raise WeChatClientException( + errcode=None, + errmsg=None, + client=self, + request=reqe.request, + response=reqe.response + ) + + return self._handle_result(res, method, url, **kwargs) + + def _handle_result(self, res, method=None, url=None, **kwargs): + result = json.loads(res.content.decode('utf-8', 'ignore'), strict=False) + if 'errcode' in result: + result['errcode'] = int(result['errcode']) + + if 'errcode' in result and result['errcode'] != 0: + errcode = result['errcode'] + errmsg = result['errmsg'] + if errcode == 42001: + logger.info('Component access token expired, fetch a new one and retry request') + self.fetch_component_access_token() + kwargs['params']['component_access_token'] = self.session.get( + 'component_access_token' + ) + return self._request( + method=method, + url_or_endpoint=url, + **kwargs + ) + elif errcode == 45009: + # api freq out of limit + raise APILimitedException( + errcode, + errmsg, + client=self, + request=res.request, + response=res + ) + else: + raise WeChatClientException( + errcode, + errmsg, + client=self, + request=res.request, + response=res + ) + + return result + + def fetch_access_token(self): + """ + 获取 component_access_token + 详情请参考 https://open.weixin.qq.com/cgi-bin/showdocument?action=dir_list\ + &t=resource/res_list&verify=1&id=open1419318587&token=&lang=zh_CN + + :return: 返回的 JSON 数据包 + """ + url = '{0}{1}'.format( + self.API_BASE_URL, + '/component/api_component_token' + ) + return self._fetch_access_token( + url=url, + data=json.dumps({ + 'component_appid': self.component_appid, + 'component_appsecret': self.component_appsecret, + 'component_verify_ticket': self.component_verify_ticket + }) + ) + + def _fetch_access_token(self, url, data): + """ The real fetch access token """ + logger.info('Fetching component access token') + res = requests.post( + url=url, + data=data + ) + try: + res.raise_for_status() + except requests.RequestException as reqe: + raise WeChatClientException( + errcode=None, + errmsg=None, + client=self, + request=reqe.request, + response=reqe.response + ) + result = res.json() + if 'errcode' in result and result['errcode'] != 0: + raise WeChatClientException( + result['errcode'], + result['errmsg'], + client=self, + request=res.request, + response=res + ) + + expires_in = 7200 + if 'expires_in' in result: + expires_in = result['expires_in'] + self.session.set( + 'component_access_token', + result['component_access_token'], + expires_in + ) + self.expires_at = int(time.time()) + expires_in + return result + + @property + def access_token(self): + """ WeChat component access token """ + access_token = self.session.get('component_access_token') + if access_token: + if not self.expires_at: + # user provided access_token, just return it + return access_token + + timestamp = time.time() + if self.expires_at - timestamp > 60: + return access_token + + self.fetch_access_token() + return self.session.get('component_access_token') + + def get(self, url, **kwargs): + return self._request( + method='get', + url_or_endpoint=url, + **kwargs + ) + + def post(self, url, **kwargs): + return self._request( + method='post', + url_or_endpoint=url, + **kwargs + ) + + +class WeChatComponent(BaseWeChatComponent): + + def create_preauthcode(self): + """ + 获取预授权码 + """ + return self.post( + '/component/api_create_preauthcode', + data={ + 'component_appid': self.component_appid + } + ) + + def query_auth(self, authorization_code): + """ + 使用授权码换取公众号的授权信息 + + :params authorization_code: 授权code,会在授权成功时返回给第三方平台,详见第三方平台授权流程说明 + """ + return self.post( + '/component/api_query_auth', + data={ + 'component_appid': self.component_appid, + 'authorization_code': authorization_code + } + ) + + def refresh_authorizer_token( + self, authorizer_appid, authorizer_refresh_token): + """ + 获取(刷新)授权公众号的令牌 + + :params authorizer_appid: 授权方appid + :params authorizer_refresh_token: 授权方的刷新令牌 + """ + return self.post( + '/component/api_authorizer_token', + data={ + 'component_appid': self.component_appid, + 'authorizer_appid': authorizer_appid, + 'authorizer_refresh_token': authorizer_refresh_token + } + ) + + def get_authorizer_info(self, authorizer_appid): + """ + 获取授权方的账户信息 + + :params authorizer_appid: 授权方appid + """ + return self.post( + '/component/api_get_authorizer_info', + data={ + 'component_appid': self.component_appid, + 'authorizer_appid': authorizer_appid, + } + ) + + def get_authorizer_option(self, authorizer_appid, option_name): + """ + 获取授权方的选项设置信息 + + :params authorizer_appid: 授权公众号appid + :params option_name: 选项名称 + """ + return self.post( + '/component/api_get_authorizer_option', + data={ + 'component_appid': self.component_appid, + 'authorizer_appid': authorizer_appid, + 'option_name': option_name + } + ) + + def set_authorizer_option( + self, authorizer_appid, option_name, option_value): + """ + 设置授权方的选项信息 + + :params authorizer_appid: 授权公众号appid + :params option_name: 选项名称 + :params option_value: 设置的选项值 + """ + return self.post( + '/component/api_set_authorizer_option', + data={ + 'component_appid': self.component_appid, + 'authorizer_appid': authorizer_appid, + 'option_name': option_name, + 'option_value': option_value + } + ) + + def get_client_by_authorization_code(self, authorization_code): + """ + 通过授权码直接获取 Client 对象 + + :params authorization_code: 授权code,会在授权成功时返回给第三方平台,详见第三方平台授权流程说明 + """ + result = self.query_auth(authorization_code) + access_token = result['authorization_info']['authorizer_access_token'] + refresh_token = result['authorization_info']['authorizer_refresh_token'] # NOQA + authorizer_appid = result['authorization_info']['authorizer_appid'] # noqa + return WeChatComponentClient( + authorizer_appid, self, access_token, refresh_token, + session=self.session + ) + + def get_client_by_appid(self, authorizer_appid): + """ + 通过 authorizer_appid 获取 Client 对象 + + :params authorizer_appid: 授权公众号appid + """ + access_token_key = '{0}_access_token'.format(authorizer_appid) + refresh_token_key = '{0}_refresh_token'.format(authorizer_appid) + access_token = self.session.get(access_token_key) + refresh_token = self.session.get(refresh_token_key) + + if not access_token: + ret = self.refresh_authorizer_token( + authorizer_appid, + refresh_token + ) + access_token = ret['authorizer_access_token'] + refresh_token = ret['authorizer_refresh_token'] + + return WeChatComponentClient( + authorizer_appid, + self, + access_token, + refresh_token, + session=self.session + ) + + def cache_component_verify_ticket(self, msg, signature, timestamp, nonce): + """ + 处理 wechat server 推送的 component_verify_ticket消息 + + :params msg: 加密内容 + :params signature: 消息签名 + :params timestamp: 时间戳 + :params nonce: 随机数 + """ + content = self.crypto.decrypt_message(msg, signature, timestamp, nonce) + message = xmltodict.parse(to_text(content))['xml'] + o = ComponentVerifyTicketMessage(message) + self.session.set(o.type, o.verify_ticket, 600) + + def get_unauthorized(self, msg, signature, timestamp, nonce): + """ + 处理取消授权通知 + + :params msg: 加密内容 + :params signature: 消息签名 + :params timestamp: 时间戳 + :params nonce: 随机数 + """ + content = self.crypto.decrypt_message(msg, signature, timestamp, nonce) + message = xmltodict.parse(to_text(content))['xml'] + return ComponentUnauthorizedMessage(message) diff --git a/sg_wechat_enterprise/we_api/crypto/__init__.py b/sg_wechat_enterprise/we_api/crypto/__init__.py new file mode 100644 index 00000000..ecdeb77c --- /dev/null +++ b/sg_wechat_enterprise/we_api/crypto/__init__.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +""" + wechatpy.crypto + ~~~~~~~~~~~~~~~~ + + This module provides some crypto tools for WeChat and WeChat enterprise + + :copyright: (c) 2014 by messense. + :license: MIT, see LICENSE for more details. +""" +from __future__ import absolute_import, unicode_literals +import time +import base64 + +from wechatpy.utils import to_text, to_binary, WeChatSigner +from wechatpy.exceptions import ( + InvalidAppIdException, + InvalidSignatureException +) +from wechatpy.crypto.base import BasePrpCrypto + + +def _get_signature(token, timestamp, nonce, encrypt): + signer = WeChatSigner() + signer.add_data(token, timestamp, nonce, encrypt) + return signer.signature + + +class PrpCrypto(BasePrpCrypto): + + def encrypt(self, text, app_id): + return self._encrypt(text, app_id) + + def decrypt(self, text, app_id): + return self._decrypt(text, app_id, InvalidAppIdException) + + +class BaseWeChatCrypto(object): + + def __init__(self, token, encoding_aes_key, _id): + encoding_aes_key = to_binary(encoding_aes_key + '=') + self.key = base64.b64decode(encoding_aes_key) + assert len(self.key) == 32 + self.token = token + self._id = _id + + def _check_signature(self, + signature, + timestamp, + nonce, + echo_str, + crypto_class=None): + _signature = _get_signature(self.token, timestamp, nonce, echo_str) + if _signature != signature: + raise InvalidSignatureException() + pc = crypto_class(self.key) + return pc.decrypt(echo_str, self._id) + + def _encrypt_message(self, + msg, + nonce, + timestamp=None, + crypto_class=None): + from wechatpy.replies import BaseReply + + xml = """ + + +{timestamp} + +""" + if isinstance(msg, BaseReply): + msg = msg.render() + timestamp = timestamp or to_binary(int(time.time())) + pc = crypto_class(self.key) + encrypt = to_text(pc.encrypt(msg, self._id)) + signature = _get_signature(self.token, timestamp, nonce, encrypt) + return to_text(xml.format( + encrypt=encrypt, + signature=signature, + timestamp=timestamp, + nonce=nonce + )) + + def _decrypt_message(self, + msg, + signature, + timestamp, + nonce, + crypto_class=None): + if not isinstance(msg, dict): + import xmltodict + + msg = xmltodict.parse(to_text(msg))['xml'] + + encrypt = msg['Encrypt'] + _signature = _get_signature(self.token, timestamp, nonce, encrypt) + if _signature != signature: + raise InvalidSignatureException() + pc = crypto_class(self.key) + return pc.decrypt(encrypt, self._id) + + +class WeChatCrypto(BaseWeChatCrypto): + + def __init__(self, token, encoding_aes_key, app_id): + super(WeChatCrypto, self).__init__(token, encoding_aes_key, app_id) + self.app_id = app_id + + def encrypt_message(self, msg, nonce, timestamp=None): + return self._encrypt_message(msg, nonce, timestamp, PrpCrypto) + + def decrypt_message(self, msg, signature, timestamp, nonce): + return self._decrypt_message( + msg, + signature, + timestamp, + nonce, + PrpCrypto + ) diff --git a/sg_wechat_enterprise/we_api/crypto/base.py b/sg_wechat_enterprise/we_api/crypto/base.py new file mode 100644 index 00000000..7f27fc06 --- /dev/null +++ b/sg_wechat_enterprise/we_api/crypto/base.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import struct +import socket +import base64 + +from wechatpy.utils import to_text, to_binary, random_string, byte2int +from wechatpy.crypto.pkcs7 import PKCS7Encoder +try: + from wechatpy.crypto.cryptography import WeChatCipher +except ImportError: + try: + from wechatpy.crypto.pycrypto import WeChatCipher + except ImportError: + raise Exception('You must install either cryptography or PyCrypto!') + + +class BasePrpCrypto(object): + + def __init__(self, key): + self.cipher = WeChatCipher(key) + + def get_random_string(self): + return random_string(16) + + def _encrypt(self, text, _id): + text = to_binary(text) + tmp_list = [] + tmp_list.append(to_binary(self.get_random_string())) + length = struct.pack(b'I', socket.htonl(len(text))) + tmp_list.append(length) + tmp_list.append(text) + tmp_list.append(to_binary(_id)) + + text = b''.join(tmp_list) + text = PKCS7Encoder.encode(text) + + ciphertext = to_binary(self.cipher.encrypt(text)) + return base64.b64encode(ciphertext) + + def _decrypt(self, text, _id, exception=None): + text = to_binary(text) + plain_text = self.cipher.decrypt(base64.b64decode(text)) + padding = byte2int(plain_text[-1]) + content = plain_text[16:-padding] + xml_length = socket.ntohl(struct.unpack(b'I', content[:4])[0]) + xml_content = to_text(content[4:xml_length + 4]) + from_id = to_text(content[xml_length + 4:]) + if from_id != _id: + exception = exception or Exception + raise exception() + return xml_content diff --git a/sg_wechat_enterprise/we_api/crypto/cryptography.py b/sg_wechat_enterprise/we_api/crypto/cryptography.py new file mode 100644 index 00000000..d9f60a03 --- /dev/null +++ b/sg_wechat_enterprise/we_api/crypto/cryptography.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.backends import default_backend + + +class WeChatCipher(object): + + def __init__(self, key): + backend = default_backend() + self.cipher = Cipher( + algorithms.AES(key), + modes.CBC(key[:16]), + backend=backend + ) + + def encrypt(self, plaintext): + encryptor = self.cipher.encryptor() + return encryptor.update(plaintext) + encryptor.finalize() + + def decrypt(self, ciphertext): + decryptor = self.cipher.decryptor() + return decryptor.update(ciphertext) + decryptor.finalize() diff --git a/sg_wechat_enterprise/we_api/crypto/pkcs7.py b/sg_wechat_enterprise/we_api/crypto/pkcs7.py new file mode 100644 index 00000000..5278fdda --- /dev/null +++ b/sg_wechat_enterprise/we_api/crypto/pkcs7.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.utils import to_binary, byte2int + + +class PKCS7Encoder(object): + block_size = 32 + + @classmethod + def encode(cls, text): + length = len(text) + padding_count = cls.block_size - length % cls.block_size + if padding_count == 0: + padding_count = cls.block_size + padding = to_binary(chr(padding_count)) + return text + padding * padding_count + + @classmethod + def decode(cls, decrypted): + padding = byte2int(decrypted[-1]) + if padding < 1 or padding > 32: + padding = 0 + return decrypted[:-padding] diff --git a/sg_wechat_enterprise/we_api/crypto/pycrypto.py b/sg_wechat_enterprise/we_api/crypto/pycrypto.py new file mode 100644 index 00000000..3a1e14a5 --- /dev/null +++ b/sg_wechat_enterprise/we_api/crypto/pycrypto.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from Crypto.Cipher import AES + + +class WeChatCipher(object): + + def __init__(self, key): + self.cipher = AES.new(key, AES.MODE_CBC, key[:16]) + + def encrypt(self, plaintext): + return self.cipher.encrypt(plaintext) + + def decrypt(self, ciphertext): + return self.cipher.decrypt(ciphertext) diff --git a/sg_wechat_enterprise/we_api/enterprise/__init__.py b/sg_wechat_enterprise/we_api/enterprise/__init__.py new file mode 100644 index 00000000..d28f7a91 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +from wechatpy.enterprise.parser import parse_message # NOQA +from wechatpy.enterprise.replies import create_reply # NOQA +from wechatpy.enterprise.crypto import WeChatCrypto # NOQA +from wechatpy.enterprise.client import WeChatClient # NOQA diff --git a/sg_wechat_enterprise/we_api/enterprise/client/__init__.py b/sg_wechat_enterprise/we_api/enterprise/client/__init__.py new file mode 100644 index 00000000..5198a9d3 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/__init__.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +from wechatpy.client.base import BaseWeChatClient +from wechatpy.enterprise.client import api + + +class WeChatClient(BaseWeChatClient): + + API_BASE_URL = 'https://qyapi.weixin.qq.com/cgi-bin/' + + user = api.WeChatUser() + department = api.WeChatDepartment() + menu = api.WeChatMenu() + message = api.WeChatMessage() + tag = api.WeChatTag() + media = api.WeChatMedia() + misc = api.WeChatMisc() + agent = api.WeChatAgent() + batch = api.WeChatBatch() + jsapi = api.WeChatJSAPI() + material = api.WeChatMaterial() + oauth = api.WeChatOAuth() + shakearound = api.WeChatShakeAround() + service = api.WeChatService() + chat = api.WeChatChat() + + def __init__(self, corp_id, secret, access_token=None, + session=None, timeout=None, auto_retry=True): + super(WeChatClient, self).__init__( + corp_id, access_token, session, timeout, auto_retry + ) + self.corp_id = corp_id + self.secret = secret + + def fetch_access_token(self): + """ Fetch access token""" + return self._fetch_access_token( + url='https://qyapi.weixin.qq.com/cgi-bin/gettoken', + params={ + 'corpid': self.corp_id, + 'corpsecret': self.secret + } + ) diff --git a/sg_wechat_enterprise/we_api/enterprise/client/api/__init__.py b/sg_wechat_enterprise/we_api/enterprise/client/api/__init__.py new file mode 100644 index 00000000..4c6232ec --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/api/__init__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +from wechatpy.enterprise.client.api.department import WeChatDepartment # NOQA +from wechatpy.enterprise.client.api.media import WeChatMedia # NOQA +from wechatpy.enterprise.client.api.message import WeChatMessage # NOQA +from wechatpy.enterprise.client.api.menu import WeChatMenu # NOQA +from wechatpy.enterprise.client.api.tag import WeChatTag # NOQA +from wechatpy.enterprise.client.api.user import WeChatUser # NOQA +from wechatpy.enterprise.client.api.misc import WeChatMisc # NOQA +from wechatpy.enterprise.client.api.agent import WeChatAgent # NOQA +from wechatpy.enterprise.client.api.batch import WeChatBatch # NOQA +from wechatpy.enterprise.client.api.jsapi import WeChatJSAPI # NOQA +from wechatpy.enterprise.client.api.material import WeChatMaterial # NOQA +from wechatpy.enterprise.client.api.oauth import WeChatOAuth # NOQA +from wechatpy.enterprise.client.api.shakearound import WeChatShakeAround # NOQA +from wechatpy.enterprise.client.api.service import WeChatService # NOQA +from wechatpy.enterprise.client.api.chat import WeChatChat # NOQA diff --git a/sg_wechat_enterprise/we_api/enterprise/client/api/agent.py b/sg_wechat_enterprise/we_api/enterprise/client/api/agent.py new file mode 100644 index 00000000..e9d3b0bd --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/api/agent.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from optionaldict import optionaldict + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatAgent(BaseWeChatAPI): + + def get(self, agent_id): + """ + 获取企业号应用 + 详情请参考 http://qydev.weixin.qq.com/wiki/index.php?title=获取企业号应用 + + :param agent_id: 授权方应用 id + :return: 返回的 JSON 数据包 + """ + return self._get( + 'agent/get', + params={ + 'agentid': agent_id + } + ) + + def set(self, + agent_id, + name=None, + description=None, + redirect_domain=None, + logo_media_id=None, + report_location_flag=0, + is_report_user=True, + is_report_enter=True): + """ + 设置企业号应用 + 详情请参考 http://qydev.weixin.qq.com/wiki/index.php?title=设置企业号应用 + + :param agent_id: 企业应用的 id + :param name: 企业应用名称 + :param description: 企业应用详情 + :param redirect_domain: 企业应用可信域名 + :param logo_media_id: 企业应用头像的mediaid,通过多媒体接口上传图片获得mediaid + :param report_location_flag: 企业应用是否打开地理位置上报 0:不上报;1:进入会话上报;2:持续上报 + :param is_report_user: 是否接收用户变更通知 + :param is_report_enter: 是否上报用户进入应用事件 + :return: 返回的 JSON 数据包 + """ + agent_data = optionaldict() + agent_data['agentid'] = agent_id + agent_data['name'] = name + agent_data['description'] = description + agent_data['redirect_domain'] = redirect_domain + agent_data['logo_mediaid'] = logo_media_id + agent_data['report_location_flag'] = report_location_flag + agent_data['isreportuser'] = 1 if is_report_user else 0 + agent_data['isreportenter'] = 1 if is_report_enter else 0 + return self._post( + 'agent/set', + data=agent_data + ) + + def list(self): + """ + 获取应用概况列表 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=获取应用概况列表 + + :return: 应用概况列表 + """ + res = self._get('agent/list') + return res['agentlist'] diff --git a/sg_wechat_enterprise/we_api/enterprise/client/api/batch.py b/sg_wechat_enterprise/we_api/enterprise/client/api/batch.py new file mode 100644 index 00000000..db775f5f --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/api/batch.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from optionaldict import optionaldict + +from wechatpy.client.api.base import BaseWeChatAPI +from wechatpy.utils import to_text + + +class WeChatBatch(BaseWeChatAPI): + + def invite_user(self, url, token, encoding_aes_key, user_ids=None, + party_ids=None, tag_ids=None, invite_tips=None): + """ + 邀请成员关注 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=异步任务接口 + + :param url: 企业应用接收企业号推送请求的访问协议和地址,支持http或https协议 + :param token: 用于生成签名 + :param encoding_aes_key: 用于消息体的加密,是AES密钥的Base64编码 + :param user_ids: 可选,成员ID列表,多个接收者用‘|’分隔,最多支持1000个。 + :param party_ids: 可选,部门ID列表,多个接收者用‘|’分隔,最多支持100个。 + :param tag_ids: 可选,标签ID列表,多个接收者用‘|’分隔。 + :param invite_tips: 可选,推送到微信上的提示语 + :return: 返回的 JSON 数据包 + """ + data = optionaldict() + data['callback'] = { + 'url': url, + 'token': token, + 'encodingaeskey': encoding_aes_key + } + if isinstance(user_ids, (tuple, list)): + user_ids = '|'.join(map(to_text, user_ids)) + if isinstance(party_ids, (tuple, list)): + party_ids = '|'.join(map(to_text, party_ids)) + if isinstance(tag_ids, (tuple, list)): + tag_ids = '|'.join(map(to_text, tag_ids)) + data['touser'] = user_ids + data['toparty'] = party_ids + data['totag'] = tag_ids + data['invite_tips'] = invite_tips + return self._post( + 'batch/inviteuser', + data=data + ) + + def sync_user(self, url, token, encoding_aes_key, media_id): + """ + 增量更新成员 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=异步任务接口 + + :param url: 企业应用接收企业号推送请求的访问协议和地址,支持http或https协议 + :param token: 用于生成签名 + :param encoding_aes_key: 用于消息体的加密,是AES密钥的Base64编码 + :param media_id: 上传的csv文件的media_id + :return: 返回的 JSON 数据包 + """ + return self._post( + 'batch/syncuser', + data={ + 'media_id': media_id, + 'callback': { + 'url': url, + 'token': token, + 'encodingaeskey': encoding_aes_key + } + } + ) + + def replace_user(self, url, token, encoding_aes_key, media_id): + """ + 全量覆盖成员 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=异步任务接口 + + :param url: 企业应用接收企业号推送请求的访问协议和地址,支持http或https协议 + :param token: 用于生成签名 + :param encoding_aes_key: 用于消息体的加密,是AES密钥的Base64编码 + :param media_id: 上传的csv文件的media_id + :return: 返回的 JSON 数据包 + """ + return self._post( + 'batch/replaceuser', + data={ + 'media_id': media_id, + 'callback': { + 'url': url, + 'token': token, + 'encodingaeskey': encoding_aes_key + } + } + ) + + def replace_party(self, url, token, encoding_aes_key, media_id): + """ + 全量覆盖部门 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=异步任务接口 + + :param url: 企业应用接收企业号推送请求的访问协议和地址,支持http或https协议 + :param token: 用于生成签名 + :param encoding_aes_key: 用于消息体的加密,是AES密钥的Base64编码 + :param media_id: 上传的csv文件的media_id + :return: 返回的 JSON 数据包 + """ + return self._post( + 'batch/replaceparty', + data={ + 'media_id': media_id, + 'callback': { + 'url': url, + 'token': token, + 'encodingaeskey': encoding_aes_key + } + } + ) + + def get_result(self, job_id): + """ + 获取异步任务结果 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=异步任务接口 + + :param job_id: 异步任务id,最大长度为64字符 + :return: 返回的 JSON 数据包 + """ + return self._get( + 'batch/getresult', + params={ + 'jobid': job_id + } + ) diff --git a/sg_wechat_enterprise/we_api/enterprise/client/api/chat.py b/sg_wechat_enterprise/we_api/enterprise/client/api/chat.py new file mode 100644 index 00000000..86c5eb18 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/api/chat.py @@ -0,0 +1,273 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from optionaldict import optionaldict + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatChat(BaseWeChatAPI): + + def create(self, chat_id, name, owner, user_list): + """ + 创建会话 + + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=企业会话接口说明 + + :param chat_id: 会话id。字符串类型,最长32个字符。只允许字符0-9及字母a-zA-Z, + 如果值内容为64bit无符号整型:要求值范围在[1, 2^63)之间, + [2^63, 2^64)为系统分配会话id区间 + :param name: 会话标题 + :param owner: 管理员userid,必须是该会话userlist的成员之一 + :param user_list: 会话成员列表,成员用userid来标识。会话成员必须在3人或以上,1000人以下 + :return: 返回的 JSON 数据包 + """ + return self._post( + 'chat/create', + data={ + 'chatid': chat_id, + 'name': name, + 'owner': owner, + 'userlist': user_list, + } + ) + + def get(self, chat_id): + """ + 获取会话 + + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=企业会话接口说明 + + :param chat_id: 会话 ID + :return: 会话信息 + """ + res = self._get('chat/get', params={'chatid': chat_id}) + return res['chat_info'] + + def update(self, chat_id, op_user, name=None, owner=None, + add_user_list=None, del_user_list=None): + """ + 修改会话 + + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=企业会话接口说明 + + :param chat_id: 会话 ID + :param op_user: 操作人 userid + :param name: 会话标题 + :param owner: 管理员userid,必须是该会话userlist的成员之一 + :param add_user_list: 会话新增成员列表,成员用userid来标识 + :param del_user_list: 会话退出成员列表,成员用userid来标识 + :return: 返回的 JSON 数据包 + """ + data = optionaldict( + chatid=chat_id, + op_user=op_user, + name=name, + owner=owner, + add_user_list=add_user_list, + del_user_list=del_user_list, + ) + return self._post('chat/update', data=data) + + def quit(self, chat_id, op_user): + """ + 退出会话 + + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=企业会话接口说明 + + :param chat_id: 会话 ID + :param op_user: 操作人 userid + :return: 返回的 JSON 数据包 + """ + return self._post( + 'chat/quit', + data={ + 'chatid': chat_id, + 'op_user': op_user, + } + ) + + def clear_notify(self, op_user, type, id): + """ + 清除会话未读状态 + + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=企业会话接口说明 + + :param op_user: 会话所有者的userid + :param type: 会话类型:single|group,分别表示:单聊|群聊 + :param id: 会话值,为userid|chatid,分别表示:成员id|会话id + :return: 返回的 JSON 数据包 + """ + return self._post( + 'chat/clearnotify', + data={ + 'op_user': op_user, + 'chat': { + 'type': type, + 'id': id, + } + } + ) + + def set_mute(self, user_mute_list): + """ + 设置成员新消息免打扰 + + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=企业会话接口说明 + + :param user_mute_list: 成员新消息免打扰参数,数组,最大支持10000个成员 + :return: 返回的 JSON 数据包 + """ + return self._post( + 'chat/setmute', + data={'user_mute_list': user_mute_list} + ) + + def send_text(self, sender, receiver_type, receiver_id, content): + """ + 发送文本消息 + + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=企业会话接口说明 + + :param sender: 发送人 + :param receiver_type: 接收人类型:single|group,分别表示:单聊|群聊 + :param receiver_id: 接收人的值,为userid|chatid,分别表示:成员id|会话id + :param content: 消息内容 + :return: 返回的 JSON 数据包 + """ + data = { + 'receiver': { + 'type': receiver_type, + 'id': receiver_id, + }, + 'sender': sender, + 'msgtype': 'text', + 'text': { + 'content': content, + } + } + return self._post('chat/send', data=data) + + def send_single_text(self, sender, receiver, content): + """ + 发送单聊文本消息 + + :param sender: 发送人 + :param receiver: 接收人成员 ID + :param content: 消息内容 + :return: 返回的 JSON 数据包 + """ + return self.send_text(sender, 'single', receiver, content) + + def send_group_text(self, sender, receiver, content): + """ + 发送群聊文本消息 + + :param sender: 发送人 + :param receiver: 会话 ID + :param content: 消息内容 + :return: 返回的 JSON 数据包 + """ + return self.send_text(sender, 'group', receiver, content) + + def send_image(self, sender, receiver_type, receiver_id, media_id): + """ + 发送图片消息 + + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=企业会话接口说明 + + :param sender: 发送人 + :param receiver_type: 接收人类型:single|group,分别表示:单聊|群聊 + :param receiver_id: 接收人的值,为userid|chatid,分别表示:成员id|会话id + :param media_id: 图片媒体文件id,可以调用上传素材文件接口获取 + :return: 返回的 JSON 数据包 + """ + data = { + 'receiver': { + 'type': receiver_type, + 'id': receiver_id, + }, + 'sender': sender, + 'msgtype': 'image', + 'image': { + 'media_id': media_id, + } + } + return self._post('chat/send', data=data) + + def send_single_image(self, sender, receiver, media_id): + """ + 发送单聊图片消息 + + :param sender: 发送人 + :param receiver: 接收人成员 ID + :param media_id: 图片媒体文件id,可以调用上传素材文件接口获取 + :return: 返回的 JSON 数据包 + """ + return self.send_image(sender, 'single', receiver, media_id) + + def send_group_image(self, sender, receiver, media_id): + """ + 发送群聊图片消息 + + :param sender: 发送人 + :param receiver: 会话 ID + :param media_id: 图片媒体文件id,可以调用上传素材文件接口获取 + :return: 返回的 JSON 数据包 + """ + return self.send_image(sender, 'group', receiver, media_id) + + def send_file(self, sender, receiver_type, receiver_id, media_id): + """ + 发送文件消息 + + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=企业会话接口说明 + + :param sender: 发送人 + :param receiver_type: 接收人类型:single|group,分别表示:单聊|群聊 + :param receiver_id: 接收人的值,为userid|chatid,分别表示:成员id|会话id + :param media_id: 文件id,可以调用上传素材文件接口获取, 文件须大于4字节 + :return: 返回的 JSON 数据包 + """ + data = { + 'receiver': { + 'type': receiver_type, + 'id': receiver_id, + }, + 'sender': sender, + 'msgtype': 'file', + 'file': { + 'media_id': media_id, + } + } + return self._post('chat/send', data=data) + + def send_single_file(self, sender, receiver, media_id): + """ + 发送单聊文件消息 + + :param sender: 发送人 + :param receiver: 接收人成员 ID + :param media_id: 文件id,可以调用上传素材文件接口获取, 文件须大于4字节 + :return: 返回的 JSON 数据包 + """ + return self.send_file(sender, 'single', receiver, media_id) + + def send_group_file(self, sender, receiver, media_id): + """ + 发送群聊文件消息 + + :param sender: 发送人 + :param receiver: 会话 ID + :param media_id: 文件id,可以调用上传素材文件接口获取, 文件须大于4字节 + :return: 返回的 JSON 数据包 + """ + return self.send_file(sender, 'group', receiver, media_id) diff --git a/sg_wechat_enterprise/we_api/enterprise/client/api/department.py b/sg_wechat_enterprise/we_api/enterprise/client/api/department.py new file mode 100644 index 00000000..096d6870 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/api/department.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from optionaldict import optionaldict + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatDepartment(BaseWeChatAPI): + + def create(self, name, parent_id=1, order=None, id=None): + """ + 创建部门 + 详情请参考 http://qydev.weixin.qq.com/wiki/index.php?title=管理部门 + + :param name: 部门名称,长度限制为 1~64 个字符 + :param parent_id: 父亲部门 id ,根部门 id 为 1 + :return: 返回的 JSON 数据包 + """ + department_data = optionaldict() + department_data['name'] = name + department_data['parentid'] = parent_id + department_data['order'] = order + department_data['id'] = id + return self._post( + 'department/create', + data=dict(department_data) + ) + + def update(self, id, name=None, parent_id=None, order=None): + """ + 更新部门 + 详情请参考 http://qydev.weixin.qq.com/wiki/index.php?title=管理部门 + + :param id: 部门 id + :param name: 部门名称 + :param parent_id: 父亲部门 id + :param order: 在父部门中的次序,从 1 开始,数字越大排序越靠后 + :return: 返回的 JSON 数据包 + """ + department_data = optionaldict() + department_data['id'] = id + department_data['name'] = name + department_data['parentid'] = parent_id + department_data['order'] = order + return self._post( + 'department/update', + data=dict(department_data) + ) + + def delete(self, id): + """ + 删除部门 + 详情请参考 http://qydev.weixin.qq.com/wiki/index.php?title=管理部门 + + :param id: 部门 id + :return: 返回的 JSON 数据包 + """ + return self._get( + 'department/delete', + params={ + 'id': id + } + ) + + def get(self): + """ + 获取部门列表 + 详情请参考 http://qydev.weixin.qq.com/wiki/index.php?title=管理部门 + + :return: 部门列表 + """ + res = self._get('department/list') + return res['department'] + + def get_users(self, id, status=0, fetch_child=0): + """ + 获取部门成员列表 + 详情请参考 http://qydev.weixin.qq.com/wiki/index.php?title=管理成员 + + :param id: 部门 id + :param status: 0 获取全部员工,1 获取已关注成员列表, + 2 获取禁用成员列表,4 获取未关注成员列表。可叠加 + :param fetch_child: 1/0:是否递归获取子部门下面的成员 + :return: 部门成员列表 + """ + fetch_child = 1 if fetch_child else 0 + res = self._get( + 'user/simplelist', + params={ + 'department_id': id, + 'status': status, + 'fetch_child': fetch_child + } + ) + return res['userlist'] + + def get_users_list(self, id, status=0, fetch_child=0): + """ + 获取部门成员详情列表 + 详情请参考 http://qydev.weixin.qq.com/wiki/index.php?title=管理成员 + + :param id: 部门 id + :param status: 0 获取全部员工,1 获取已关注成员列表, + 2 获取禁用成员列表,4 获取未关注成员列表。可叠加 + :param fetch_child: 1/0:是否递归获取子部门下面的成员 + :return: 部门成员列表 + """ + fetch_child = 1 if fetch_child else 0 + res = self._get( + 'user/list', + params={ + 'department_id': id, + 'status': status, + 'fetch_child': fetch_child + } + ) + return res['userlist'] \ No newline at end of file diff --git a/sg_wechat_enterprise/we_api/enterprise/client/api/jsapi.py b/sg_wechat_enterprise/we_api/enterprise/client/api/jsapi.py new file mode 100644 index 00000000..b0661ea2 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/api/jsapi.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import time + +from wechatpy.client.api.base import BaseWeChatAPI +from wechatpy.utils import WeChatSigner + + +class WeChatJSAPI(BaseWeChatAPI): + + def get_ticket(self): + """ + 获取微信 JS-SDK ticket + + http://qydev.weixin.qq.com/wiki/index.php?title=%E5%BE%AE%E4%BF%A1JS%E6%8E%A5%E5%8F%A3 + + :return: 返回的 JSON 数据包 + """ + return self._get('get_jsapi_ticket') + + def get_jsapi_ticket(self): + """ + 获取微信 JS-SDK ticket + + 该方法会通过 session 对象自动缓存管理 ticket + + :return: ticket + """ + ticket = self.session.get('jsapi_ticket') + expires_at = self.session.get('jsapi_ticket_expires_at', 0) + if not ticket or expires_at < int(time.time()): + jsapi_ticket = self.get_ticket() + ticket = jsapi_ticket['ticket'] + expires_at = int(time.time()) + int(jsapi_ticket['expires_in']) + self.session.set('jsapi_ticket', ticket) + self.session.set('jsapi_ticket_expires_at', expires_at) + return ticket + + def get_jsapi_signature(self, noncestr, ticket, timestamp, url): + data = [ + 'noncestr={noncestr}'.format(noncestr=noncestr), + 'jsapi_ticket={ticket}'.format(ticket=ticket), + 'timestamp={timestamp}'.format(timestamp=timestamp), + 'url={url}'.format(url=url), + ] + signer = WeChatSigner(delimiter=b'&') + signer.add_data(*data) + return signer.signature diff --git a/sg_wechat_enterprise/we_api/enterprise/client/api/material.py b/sg_wechat_enterprise/we_api/enterprise/client/api/material.py new file mode 100644 index 00000000..b5547904 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/api/material.py @@ -0,0 +1,201 @@ +# encoding: utf-8 +from __future__ import absolute_import, unicode_literals +import requests + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatMaterial(BaseWeChatAPI): + + def add_articles(self, articles): + """ + 新增永久图文素材 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=%E4%B8%8A%E4%BC%A0%E6%B0%B8%E4%B9%85%E7%B4%A0%E6%9D%90 + + :param articles: 图文素材数组 + :return: 返回的 JSON 数据包 + """ + articles_data = [] + for article in articles: + articles_data.append({ + 'thumb_media_id': article['thumb_media_id'], + 'title': article['title'], + 'content': article['content'], + 'author': article.get('author', ''), + 'content_source_url': article.get('content_source_url', ''), + 'digest': article.get('digest', ''), + 'show_cover_pic': article.get('show_cover_pic', '0') + }) + return self._post( + 'material/add_mpnews', + data={ + "mpnews": { + "articles": articles_data + } + } + ) + + def add(self, agent_id, media_type, media_file): + """ + 新增其它类型永久素材 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=%E4%B8%8A%E4%BC%A0%E6%B0%B8%E4%B9%85%E7%B4%A0%E6%9D%90 + + :param agent_id: 企业应用的id + :param media_type: 媒体文件类型,分别有图片(image)、语音(voice)、视频(video)普通文件(file) + :param media_file: 要上传的文件,一个 File-object + :return: 返回的 JSON 数据包 + """ + params = { + 'agentid': agent_id, + 'type': media_type, + } + return self._post( + url='material/add_material', + params=params, + files={ + 'media': media_file + } + ) + + def get_url(self, agent_id, media_id): + """ + 获取永久素材下载地址 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=%E8%8E%B7%E5%8F%96%E6%B0%B8%E4%B9%85%E7%B4%A0%E6%9D%90 + + :param agent_id: 企业应用的id + :param media_id: 媒体文件 ID + :return: 临时素材下载地址 + """ + parts = ( + 'https://qyapi.weixin.qq.com/cgi-bin/material/get', + '?access_token=', + self.access_token, + '&media_id=', + media_id, + '&agentid=', + agent_id, + ) + return ''.join(parts) + + def get(self, agent_id, media_id): + """ + 获取永久素材 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=%E8%8E%B7%E5%8F%96%E6%B0%B8%E4%B9%85%E7%B4%A0%E6%9D%90 + + :param agent_id: 企业应用的id + :param media_id: 媒体文件 ID + :return: requests 的 Response 实例 + """ + res = requests.get(self.get_url(agent_id, media_id)) + + return res + + def get_articles(self, agent_id, media_id): + """ + 获取永久素材:图文消息素材 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=%E8%8E%B7%E5%8F%96%E6%B0%B8%E4%B9%85%E7%B4%A0%E6%9D%90 + + :param agent_id: 企业应用的id + :param media_id: 媒体文件 ID + :return: 返回的 JSON 数据包 + """ + return self._get( + 'material/get', + params={ + 'agentid': agent_id, + 'media_id': media_id, + } + ) + + def delete(self, agent_id, media_id): + """ + 删除永久素材 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=%E5%88%A0%E9%99%A4%E6%B0%B8%E4%B9%85%E7%B4%A0%E6%9D%90 + + :param agent_id: 企业应用的id + :param media_id: 媒体文件 ID + :return: 返回的 JSON 数据包 + """ + return self._get( + 'material/del', + params={ + 'agentid': agent_id, + 'media_id': media_id, + } + ) + + def update_articles(self, agent_id, media_id, articles): + """ + 修改永久图文素材 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=%E4%BF%AE%E6%94%B9%E6%B0%B8%E4%B9%85%E5%9B%BE%E6%96%87%E7%B4%A0%E6%9D%90 + + :param media_id: 要修改的图文消息的 id + :param index: 要更新的文章在图文消息中的位置(多图文消息时,此字段才有意义),第一篇为 0 + :param articles: 图文素材数组 + :return: 返回的 JSON 数据包 + """ + articles_data = [] + for article in articles: + articles_data.append({ + 'thumb_media_id': article['thumb_media_id'], + 'title': article['title'], + 'content': article['content'], + 'author': article.get('author', ''), + 'content_source_url': article.get('content_source_url', ''), + 'digest': article.get('digest', ''), + 'show_cover_pic': article.get('show_cover_pic', '0') + }) + return self._post( + 'material/update_news', + data={ + 'agentid': agent_id, + 'media_id': media_id, + 'articles': articles_data + } + ) + + def get_count(self, agent_id): + """ + 获取素材总数 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=%E8%8E%B7%E5%8F%96%E7%B4%A0%E6%9D%90%E6%80%BB%E6%95%B0 + + :param agent_id: 企业应用的id + :return: 返回的 JSON 数据包 + """ + return self._get( + 'material/get_count', + params={ + 'agent_id': agent_id, + } + ) + + def batchget(self, agent_id, media_type, offset=0, count=20): + """ + 批量获取永久素材列表 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=%E8%8E%B7%E5%8F%96%E7%B4%A0%E6%9D%90%E5%88%97%E8%A1%A8 + + :param agent_id: 企业应用的id + :param media_type: 媒体文件类型,分别有图文(mpnews)、图片(image)、 + 语音(voice)、视频(video)和文件(file) + :param offset: 从全部素材的该偏移位置开始返回,0 表示从第一个素材返回 + :param count: 返回素材的数量,取值在1到20之间 + :return: 返回的 JSON 数据包 + """ + return self._post( + 'material/batchget', + data={ + 'agent_id': agent_id, + 'type': media_type, + 'offset': offset, + 'count': count + } + ) diff --git a/sg_wechat_enterprise/we_api/enterprise/client/api/media.py b/sg_wechat_enterprise/we_api/enterprise/client/api/media.py new file mode 100644 index 00000000..0541ee54 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/api/media.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import requests + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatMedia(BaseWeChatAPI): + + def upload(self, media_type, media_file): + """ + 上传临时素材文件 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=%E4%B8%8A%E4%BC%A0%E4%B8%B4%E6%97%B6%E7%B4%A0%E6%9D%90%E6%96%87%E4%BB%B6 + + :param media_type: 媒体文件类型,分别有图片(image)、语音(voice)、视频(video)和普通文件(file) + :param media_file: 要上传的文件,一个 File-object + :return: 返回的 JSON 数据包 + """ + return self._post( + 'media/upload', + params={ + 'type': media_type + }, + files={ + 'media': media_file + } + ) + + def upload_image(self, media_file): + """ + 上传卡券logo + :param media_file: 要上传的文件 + :return: + """ + return self._post( + 'media/uploadimg', + params={ + 'type': 'card_logo' + }, + files={ + 'media': media_file + } + ) + + def download(self, media_id): + """ + 获取临时素材文件 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=%E8%8E%B7%E5%8F%96%E4%B8%B4%E6%97%B6%E7%B4%A0%E6%9D%90%E6%96%87%E4%BB%B6 + + :param media_id: 媒体文件 ID + :return: requests 的 Response 实例 + """ + return requests.get(self.get_url(media_id)) + + def get_url(self, media_id): + """ + 获取临时素材下载地址 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=%E8%8E%B7%E5%8F%96%E4%B8%B4%E6%97%B6%E7%B4%A0%E6%9D%90%E6%96%87%E4%BB%B6 + + :param media_id: 媒体文件 ID + :return: 临时素材下载地址 + """ + parts = ( + 'https://qyapi.weixin.qq.com/cgi-bin/media/get', + '?access_token=', + self.access_token, + '&media_id=', + media_id + ) + return ''.join(parts) diff --git a/sg_wechat_enterprise/we_api/enterprise/client/api/menu.py b/sg_wechat_enterprise/we_api/enterprise/client/api/menu.py new file mode 100644 index 00000000..2b41f882 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/api/menu.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.client.api.base import BaseWeChatAPI +from wechatpy.exceptions import WeChatClientException + + +class WeChatMenu(BaseWeChatAPI): + + def create(self, agent_id, menu_data): + return self._post( + 'menu/create', + params={ + 'agentid': agent_id + }, + data=menu_data + ) + + def get(self, agent_id): + try: + return self._get( + 'menu/get', + params={ + 'agentid': agent_id + } + ) + except WeChatClientException as e: + if e.errcode == 46003: + # menu not exist + return None + else: + raise e + + def delete(self, agent_id): + return self._get( + 'menu/delete', + params={ + 'agentid': agent_id + } + ) + + def update(self, agent_id, menu_data): + self.delete(agent_id) + return self.create(agent_id, menu_data) diff --git a/sg_wechat_enterprise/we_api/enterprise/client/api/message.py b/sg_wechat_enterprise/we_api/enterprise/client/api/message.py new file mode 100644 index 00000000..b6a93e28 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/api/message.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from optionaldict import optionaldict + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatMessage(BaseWeChatAPI): + + def _send_message(self, agent_id, user_ids, party_ids='', + tag_ids='', msg=None): + msg = msg or {} + if isinstance(user_ids, (tuple, list)): + user_ids = '|'.join(user_ids) + if isinstance(party_ids, (tuple, list)): + party_ids = '|'.join(party_ids) + if isinstance(tag_ids, (tuple, list)): + tag_ids = '|'.join(tag_ids) + + data = { + 'touser': user_ids, + 'toparty': party_ids, + 'totag': tag_ids, + 'agentid': agent_id + } + data.update(msg) + return self._post( + 'message/send', + data=data + ) + + def send_text(self, agent_id, user_ids, content, + party_ids='', tag_ids='', safe=0): + return self._send_message( + agent_id, + user_ids, + party_ids, + tag_ids, + msg={ + 'msgtype': 'text', + 'text': {'content': content}, + 'safe': safe + } + ) + + def send_image(self, agent_id, user_ids, media_id, + party_ids='', tag_ids='', safe=0): + return self._send_message( + agent_id, + user_ids, + party_ids, + tag_ids, + msg={ + 'msgtype': 'image', + 'image': { + 'media_id': media_id + }, + 'safe': safe + } + ) + + def send_voice(self, agent_id, user_ids, media_id, + party_ids='', tag_ids='', safe=0): + return self._send_message( + agent_id, + user_ids, + party_ids, + tag_ids, + msg={ + 'msgtype': 'voice', + 'voice': { + 'media_id': media_id + }, + 'safe': safe + } + ) + + def send_video(self, agent_id, user_ids, media_id, title=None, + description=None, party_ids='', tag_ids='', safe=0): + video_data = optionaldict() + video_data['media_id'] = media_id + video_data['title'] = title + video_data['description'] = description + + return self._send_message( + agent_id, + user_ids, + party_ids, + tag_ids, + msg={ + 'msgtype': 'video', + 'video': dict(video_data), + 'safe': safe + } + ) + + def send_file(self, agent_id, user_ids, media_id, + party_ids='', tag_ids='', safe=0): + return self._send_message( + agent_id, + user_ids, + party_ids, + tag_ids, + msg={ + 'msgtype': 'file', + 'file': { + 'media_id': media_id + }, + 'safe': safe + } + ) + + def send_articles(self, agent_id, user_ids, articles, + party_ids='', tag_ids=''): + articles_data = [] + for article in articles: + articles_data.append({ + 'title': article['title'], + 'description': article['description'], + 'url': article['url'], + 'picurl': article['image'] + }) + return self._send_message( + agent_id, + user_ids, + party_ids, + tag_ids, + msg={ + 'msgtype': 'news', + 'news': { + 'articles': articles_data + } + } + ) + + def send_mp_articles(self, agent_id, user_ids, articles, + party_ids='', tag_ids='', safe=0): + articles_data = [] + for article in articles: + articles_data.append({ + 'thumb_media_id': article['thumb_media_id'], + 'author': article['author'], + 'title': article['title'], + 'content': article['content'], + 'content_source_url': article['content_source_url'], + 'digest': article['digest'], + 'show_cover_pic': article['show_cover_pic'] + }) + return self._send_message( + agent_id, + user_ids, + party_ids, + tag_ids, + msg={ + 'msgtype': 'mpnews', + 'mpnews': { + 'articles': articles_data + }, + 'safe': safe + } + ) diff --git a/sg_wechat_enterprise/we_api/enterprise/client/api/misc.py b/sg_wechat_enterprise/we_api/enterprise/client/api/misc.py new file mode 100644 index 00000000..bec63870 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/api/misc.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatMisc(BaseWeChatAPI): + + def get_wechat_ips(self): + """ + 获取微信服务器 IP 列表 + + :return: IP 地址列表 + """ + res = self._get('getcallbackip') + return res['ip_list'] diff --git a/sg_wechat_enterprise/we_api/enterprise/client/api/oauth.py b/sg_wechat_enterprise/we_api/enterprise/client/api/oauth.py new file mode 100644 index 00000000..cf9e40d7 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/api/oauth.py @@ -0,0 +1,51 @@ +# encoding: utf-8 +from __future__ import absolute_import, unicode_literals +import six + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatOAuth(BaseWeChatAPI): + + OAUTH_BASE_URL = 'https://open.weixin.qq.com/connect/oauth2/authorize' + + def authorize_url(self, redirect_uri, state=None): + """ + 获取授权地址 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=OAuth%E9%AA%8C%E8%AF%81%E6%8E%A5%E5%8F%A3 + + :param redirect_url: 授权后重定向的回调链接地址 + :param state: 重定向后会带上 state 参数 + :return: 返回的 JSON 数据包 + """ + redirect_uri = six.moves.urllib.parse.quote(redirect_uri) + url_list = [ + self.OAUTH_BASE_URL, + '?appid=', + self._client.corp_id, + '&redirect_uri=', + redirect_uri, + '&response_type=code&scope=snsapi_base', + ] + if state: + url_list.extend(['&state=', state]) + url_list.append('#wechat_redirect') + return ''.join(url_list) + + def get_user_info(self, code): + """ + 根据 code 获取用户信息 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=OAuth%E9%AA%8C%E8%AF%81%E6%8E%A5%E5%8F%A3 + + :param code: 通过成员授权获取到的code + :return: 返回的 JSON 数据包 + """ + + return self._get( + 'user/getuserinfo', + params={ + 'code': code, + } + ) diff --git a/sg_wechat_enterprise/we_api/enterprise/client/api/service.py b/sg_wechat_enterprise/we_api/enterprise/client/api/service.py new file mode 100644 index 00000000..07e2bcd8 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/api/service.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatService(BaseWeChatAPI): + + def get_provider_token(self, provider_secret): + """ + 获取应用提供商凭证 + + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=获取应用提供商凭证 + + :param provider_secret: 提供商的secret,在提供商管理页面可见 + :return: 返回的 JSON 数据包 + """ + return self._post( + 'service/get_provider_token', + data={ + 'corpid': self._client.corp_id, + 'provider_secret': provider_secret, + } + ) + + def get_login_info(self, provider_access_token, auth_code): + """ + 获取企业号管理员登录信息 + + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=获取企业号管理员登录信息 + + :param provider_access_token: 服务提供商的 accesstoken + :param auth_code: OAuth 2.0 授权企业号管理员登录产生的 code + :return: 返回的 JSON 数据包 + """ + return self._post( + 'service/get_login_info', + params={ + 'provider_access_token': provider_access_token, + }, + data={ + 'auth_code': auth_code, + } + ) diff --git a/sg_wechat_enterprise/we_api/enterprise/client/api/shakearound.py b/sg_wechat_enterprise/we_api/enterprise/client/api/shakearound.py new file mode 100644 index 00000000..27716498 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/api/shakearound.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python +# encoding: utf-8 +from __future__ import absolute_import, unicode_literals + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatShakeAround(BaseWeChatAPI): + + def get_shake_info(self, ticket): + """ + 获取摇周边的设备及用户信息 + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=获取设备及用户信息 + + :param ticket: 摇周边业务的ticket,可在摇到的 URL 中得到,ticket 生效时间为30分钟 + :return: 设备及用户信息 + """ + res = self._post( + 'shakearound/getshakeinfo', + data={ + 'ticket': ticket + } + ) + return res['data'] diff --git a/sg_wechat_enterprise/we_api/enterprise/client/api/tag.py b/sg_wechat_enterprise/we_api/enterprise/client/api/tag.py new file mode 100644 index 00000000..c6be2733 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/api/tag.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatTag(BaseWeChatAPI): + + def create(self, name): + return self._post( + 'tag/create', + data={ + 'tagname': name + } + ) + + def update(self, tag_id, name): + return self._post( + 'tag/update', + data={ + 'tagid': tag_id, + 'tagname': name + } + ) + + def delete(self, tag_id): + return self._get( + 'tag/delete', + params={ + 'tagid': tag_id + } + ) + + def get_users(self, tag_id): + return self._get( + 'tag/get', + params={ + 'tagid': tag_id + } + ) + + def add_users(self, tag_id, user_ids): + return self._post( + 'tag/addtagusers', + data={ + 'tagid': tag_id, + 'userlist': user_ids + } + ) + + def delete_users(self, tag_id, user_ids): + return self._post( + 'tag/deltagusers', + data={ + 'tagid': tag_id, + 'userlist': user_ids + } + ) + + def list(self): + res = self._get('tag/list') + return res['taglist'] diff --git a/sg_wechat_enterprise/we_api/enterprise/client/api/user.py b/sg_wechat_enterprise/we_api/enterprise/client/api/user.py new file mode 100644 index 00000000..97b53b86 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/client/api/user.py @@ -0,0 +1,160 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from optionaldict import optionaldict + +from wechatpy.client.api.base import BaseWeChatAPI + + +class WeChatUser(BaseWeChatAPI): + + def create(self, user_id, name, department=None, position=None, + mobile=None, gender=0, tel=None, email=None, + weixin_id=None, extattr=None, avatar_mediaid=None, to_invite=True): + """ + 创建成员 + 详情请参考 http://qydev.weixin.qq.com/wiki/index.php?title=管理成员 + """ + user_data = optionaldict() + user_data['userid'] = user_id + user_data['name'] = name + user_data['gender'] = gender + user_data['department'] = department + user_data['position'] = position + user_data['mobile'] = mobile + user_data['tel'] = tel + user_data['email'] = email + user_data['weixinid'] = weixin_id + user_data['extattr'] = extattr + user_data['avatar_mediaid'] = avatar_mediaid + user_data['to_invite'] = to_invite + + return self._post( + 'user/create', + data=user_data + ) + + def update(self, user_id, name=None, department=None, position=None, + mobile=None, gender=None, tel=None, email=None, + weixin_id=None, enable=None, extattr=None, avatar_mediaid=None): + """ + 更新成员 + 详情请参考 http://qydev.weixin.qq.com/wiki/index.php?title=管理成员 + """ + user_data = optionaldict() + user_data['userid'] = user_id + user_data['name'] = name + user_data['gender'] = gender + user_data['department'] = department + user_data['position'] = position + user_data['mobile'] = mobile + user_data['tel'] = tel + user_data['email'] = email + user_data['weixinid'] = weixin_id + user_data['extattr'] = extattr + user_data['enable'] = enable + user_data['avatar_mediaid'] = avatar_mediaid + + return self._post( + 'user/update', + data=user_data + ) + + def delete(self, user_id): + """ + 删除成员 + 详情请参考 http://qydev.weixin.qq.com/wiki/index.php?title=管理成员 + """ + return self._get( + 'user/delete', + params={ + 'userid': user_id + } + ) + + def get(self, user_id): + """ + 获取成员 + 详情请参考 http://qydev.weixin.qq.com/wiki/index.php?title=管理成员 + """ + return self._get( + 'user/get', + params={ + 'userid': user_id + } + ) + + def verify(self, user_id): + return self._get( + 'user/authsucc', + params={ + 'userid': user_id + } + ) + + def get_info(self, agent_id, code): + return self._get( + 'user/getuserinfo', + params={ + 'agentid': agent_id, + 'code': code + } + ) + + def batch_delete(self, user_ids): + """ + 批量删除成员 + 详情请参考 http://qydev.weixin.qq.com/wiki/index.php?title=管理成员 + """ + return self._post( + 'user/batchdelete', + data={ + 'useridlist': user_ids + } + ) + + def list(self, department_id, fetch_child=False, status=0): + """ + 批量获取部门成员 + 详情请参考 http://qydev.weixin.qq.com/wiki/index.php?title=管理成员 + """ + res = self._get( + 'user/list', + params={ + 'department_id': department_id, + 'fetch_child': 1 if fetch_child else 0, + 'status': status + } + ) + return res['userlist'] + + def convert_to_openid(self, user_id, agent_id=None): + """ + user_id 转成 openid + + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=Userid%E4%B8%8Eopenid%E4%BA%92%E6%8D%A2%E6%8E%A5%E5%8F%A3 + + :param user_id: 企业号内的成员 ID + :param agent_id: 可选,需要发送红包的应用ID,若只是使用微信支付和企业转账,则无需该参数 + :return: 返回的 JSON 数据包 + """ + data = optionaldict() + data['userid'] = user_id + data['agentid'] = agent_id + return self._post('user/convert_to_openid', data=data) + + def convert_to_user_id(self, openid): + """ + openid 转成 user_id + + 详情请参考 + http://qydev.weixin.qq.com/wiki/index.php?title=Userid%E4%B8%8Eopenid%E4%BA%92%E6%8D%A2%E6%8E%A5%E5%8F%A3 + + :param openid: 在使用微信支付、微信红包和企业转账之后,返回结果的openid + :return: 该 openid 在企业号中对应的成员 user_id + """ + res = self._post( + 'user/convert_to_userid', + data={'openid': openid} + ) + return res['userid'] diff --git a/sg_wechat_enterprise/we_api/enterprise/crypto.py b/sg_wechat_enterprise/we_api/enterprise/crypto.py new file mode 100644 index 00000000..7bd46f9c --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/crypto.py @@ -0,0 +1,46 @@ +from __future__ import absolute_import, unicode_literals + +from wechatpy.crypto import BasePrpCrypto, BaseWeChatCrypto +from wechatpy.enterprise.exceptions import InvalidCorpIdException + + +class PrpCrypto(BasePrpCrypto): + + def encrypt(self, text, corp_id): + return self._encrypt(text, corp_id) + + def decrypt(self, text, corp_id): + return self._decrypt(text, corp_id, InvalidCorpIdException) + + +class WeChatCrypto(BaseWeChatCrypto): + + def __init__(self, token, encoding_aes_key, corp_id): + super(WeChatCrypto, self).__init__(token, encoding_aes_key, corp_id) + self.corp_id = corp_id + + def check_signature(self, signature, timestamp, nonce, echo_str): + return self._check_signature( + signature, + timestamp, + nonce, + echo_str, + PrpCrypto + ) + + def encrypt_message(self, msg, nonce, timestamp=None): + return self._encrypt_message( + msg, + nonce, + timestamp, + PrpCrypto + ) + + def decrypt_message(self, msg, signature, timestamp, nonce): + return self._decrypt_message( + msg, + signature, + timestamp, + nonce, + PrpCrypto + ) diff --git a/sg_wechat_enterprise/we_api/enterprise/events.py b/sg_wechat_enterprise/we_api/enterprise/events.py new file mode 100644 index 00000000..6b3ef752 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/events.py @@ -0,0 +1,163 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +from wechatpy.fields import IntegerField, BaseField +from wechatpy import events + + +EVENT_TYPES = {} + + +def register_event(event_type): + def register(cls): + EVENT_TYPES[event_type] = cls + return cls + return register + + +@register_event('subscribe') +class SubscribeEvent(events.SubscribeEvent): + """ + 成员关注事件 + 详情请参阅 + http://qydev.weixin.qq.com/wiki/index.php?title=接受事件 + """ + agent = IntegerField('AgentID', 0) + + +@register_event('unsubscribe') +class UnsubscribeEvent(events.UnsubscribeEvent): + """ + 成员取消关注事件 + 详情请参阅 + http://qydev.weixin.qq.com/wiki/index.php?title=接受事件 + """ + agent = IntegerField('AgentID', 0) + + +@register_event('click') +class ClickEvent(events.ClickEvent): + """ + 点击菜单拉取消息事件 + 详情请参阅 + http://qydev.weixin.qq.com/wiki/index.php?title=接受事件 + """ + agent = IntegerField('AgentID', 0) + + +@register_event('xml') +class ViewEvent(events.ViewEvent): + """ + 点击菜单跳转链接事件 + 详情请参阅 + http://qydev.weixin.qq.com/wiki/index.php?title=接受事件 + """ + agent = IntegerField('AgentID', 0) + + +@register_event('location') +class LocationEvent(events.LocationEvent): + """ + 上报地理位置事件 + 详情请参阅 + http://qydev.weixin.qq.com/wiki/index.php?title=接受事件 + """ + agent = IntegerField('AgentID', 0) + + +@register_event('scancode_push') +class ScanCodePushEvent(events.ScanCodePushEvent): + """ + 扫码推事件的事件 + 详情请参阅 + http://qydev.weixin.qq.com/wiki/index.php?title=接受事件 + """ + agent = IntegerField('AgentID', 0) + + +@register_event('scancode_waitmsg') +class ScanCodeWaitMsgEvent(events.ScanCodeWaitMsgEvent): + """ + 扫码推事件且弹出“消息接收中”提示框的事件 + 详情请参阅 + http://qydev.weixin.qq.com/wiki/index.php?title=接受事件 + """ + agent = IntegerField('AgentID', 0) + + +@register_event('pic_sysphoto') +class PicSysPhotoEvent(events.PicSysPhotoEvent): + """ + 弹出系统拍照发图事件 + 详情请参阅 + http://qydev.weixin.qq.com/wiki/index.php?title=接受事件 + """ + agent = IntegerField('AgentID', 0) + + +@register_event('pic_photo_or_album') +class PicPhotoOrAlbumEvent(events.PicPhotoOrAlbumEvent): + """ + 弹出拍照或相册发图事件 + 详情请参阅 + http://qydev.weixin.qq.com/wiki/index.php?title=接受事件 + """ + agent = IntegerField('AgentID', 0) + + +@register_event('pic_weixin') +class PicWeChatEvent(events.PicWeChatEvent): + """ + 弹出微信相册发图器事件 + 详情请参阅 + http://qydev.weixin.qq.com/wiki/index.php?title=接受事件 + """ + agent = IntegerField('AgentID', 0) + + +@register_event('location_select') +class LocationSelectEvent(events.LocationSelectEvent): + """ + 弹出地理位置选择器事件 + 详情请参阅 + http://qydev.weixin.qq.com/wiki/index.php?title=接受事件 + """ + agent = IntegerField('AgentID', 0) + + +@register_event('enter_agent') +class EnterAgentEvent(events.BaseEvent): + """ + 用户进入应用的事件推送 + 详情请参阅 + http://qydev.weixin.qq.com/wiki/index.php?title=接受事件 + """ + agent = IntegerField('AgentID', 0) + event = 'enter_agent' + + +@register_event('batch_job_result') +class BatchJobResultEvent(events.BaseEvent): + """ + 异步任务完成事件 + 详情请参阅 + http://qydev.weixin.qq.com/wiki/index.php?title=接受事件 + """ + event = 'batch_job_result' + batch_job = BaseField('BatchJob') + + @property + def job_id(self): + return self.batch_job['JobId'] + + @property + def job_type(self): + return self.batch_job['JobType'] + + @property + def err_code(self): + return self.batch_job['ErrCode'] + + @property + def err_msg(self): + return self.batch_job['ErrMsg'] diff --git a/sg_wechat_enterprise/we_api/enterprise/exceptions.py b/sg_wechat_enterprise/we_api/enterprise/exceptions.py new file mode 100644 index 00000000..80c136d8 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/exceptions.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.exceptions import WeChatException + + +class InvalidCorpIdException(WeChatException): + + def __init__(self, errcode=-40005, errmsg='Invalid corp_id'): + super(InvalidCorpIdException, self).__init__(errcode, errmsg) diff --git a/sg_wechat_enterprise/we_api/enterprise/messages.py b/sg_wechat_enterprise/we_api/enterprise/messages.py new file mode 100644 index 00000000..8ff46f89 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/messages.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +from wechatpy.fields import IntegerField, StringField +from wechatpy import messages + + +MESSAGE_TYPES = {} + + +def register_message(msg_type): + def register(cls): + MESSAGE_TYPES[msg_type] = cls + return cls + return register + + +@register_message('text') +class TextMessage(messages.TextMessage): + agent = IntegerField('AgentID', 0) + + +@register_message('image') +class ImageMessage(messages.ImageMessage): + agent = IntegerField('AgentID', 0) + + +@register_message('voice') +class VoiceMessage(messages.VoiceMessage): + agent = IntegerField('AgentID', 0) + + +@register_message('shortvideo') +class ShortVideoMessage(messages.ShortVideoMessage): + agent = IntegerField('AgentID', 0) + + +@register_message('video') +class VideoMessage(messages.VideoMessage): + agent = IntegerField('AgentID', 0) + + +@register_message('location') +class LocationMessage(messages.LocationMessage): + agent = IntegerField('AgentID', 0) + +@register_message('link') +class LinkMessage(messages.LinkMessage): + agent = IntegerField('AgentID', 0) + pic_url = StringField('PicUrl') diff --git a/sg_wechat_enterprise/we_api/enterprise/parser.py b/sg_wechat_enterprise/we_api/enterprise/parser.py new file mode 100644 index 00000000..a4b8b4e0 --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/parser.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import xmltodict + +from wechatpy.messages import UnknownMessage +from wechatpy.utils import to_text +from wechatpy.enterprise.messages import MESSAGE_TYPES +from wechatpy.enterprise.events import EVENT_TYPES + + +def parse_message(xml): + if not xml: + return + message = xmltodict.parse(to_text(xml))['xml'] + message_type = message['MsgType'].lower() + if message_type == 'event': + event_type = message['Event'].lower() + message_class = EVENT_TYPES.get(event_type, UnknownMessage) + else: + message_class = MESSAGE_TYPES.get(message_type, UnknownMessage) + return message_class(message) diff --git a/sg_wechat_enterprise/we_api/enterprise/replies.py b/sg_wechat_enterprise/we_api/enterprise/replies.py new file mode 100644 index 00000000..0597f13a --- /dev/null +++ b/sg_wechat_enterprise/we_api/enterprise/replies.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import six + +from wechatpy import replies +from wechatpy.fields import IntegerField + + +REPLY_TYPES = {} + + +def register_reply(reply_type): + def register(cls): + REPLY_TYPES[reply_type] = cls + return cls + return register + + +@register_reply('text') +class TextReply(replies.TextReply): + agent = IntegerField('AgentID', 0) + + +@register_reply('image') +class ImageReply(replies.ImageReply): + agent = IntegerField('AgentID', 0) + + +@register_reply('voice') +class VoiceReply(replies.VoiceReply): + agent = IntegerField('AgentID', 0) + + +@register_reply('video') +class VideoReply(replies.VideoReply): + agent = IntegerField('AgentID', 0) + + +@register_reply('news') +class ArticlesReply(replies.ArticlesReply): + agent = IntegerField('AgentID', 0) + + +def create_reply(reply, message=None, render=False): + r = None + if isinstance(reply, replies.BaseReply): + r = reply + if message: + r.source = message.target + r.target = message.source + r.agent = message.agent + elif isinstance(reply, six.string_types): + r = TextReply( + message=message, + content=reply + ) + elif isinstance(reply, (tuple, list)): + if len(reply) > 10: + raise AttributeError("Can't add more than 10 articles" + " in an ArticlesReply") + r = ArticlesReply( + message=message, + articles=reply + ) + if r and render: + return r.render() + return r diff --git a/sg_wechat_enterprise/we_api/events.py b/sg_wechat_enterprise/we_api/events.py new file mode 100644 index 00000000..ac2d217f --- /dev/null +++ b/sg_wechat_enterprise/we_api/events.py @@ -0,0 +1,629 @@ +# -*- coding: utf-8 -*- +""" + wechatpy.events + ~~~~~~~~~~~~~~~~ + + This module contains all the events WeChat callback uses. + + :copyright: (c) 2014 by messense. + :license: MIT, see LICENSE for more details. +""" +from __future__ import absolute_import, unicode_literals + +from wechatpy.fields import ( + StringField, + FloatField, + IntegerField, + BaseField, + Base64DecodeField, + DateTimeField +) +from wechatpy.messages import BaseMessage + + +EVENT_TYPES = {} + + +def register_event(event_type): + """ + Register the event class so that they can be accessed from EVENT_TYPES + + :param event_type: Event type + """ + def register(cls): + EVENT_TYPES[event_type] = cls + return cls + return register + + +class BaseEvent(BaseMessage): + """Base class for all events""" + type = 'event' + event = '' + + +@register_event('subscribe') +class SubscribeEvent(BaseEvent): + """ + 用户关注事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/2/5baf56ce4947d35003b86a9805634b1e.html + """ + event = 'subscribe' + + +@register_event('unsubscribe') +class UnsubscribeEvent(BaseEvent): + """ + 用户取消关注事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/2/5baf56ce4947d35003b86a9805634b1e.html + """ + event = 'unsubscribe' + + +@register_event('subscribe_scan') +class SubscribeScanEvent(BaseEvent): + """ + 用户扫描二维码关注事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/2/5baf56ce4947d35003b86a9805634b1e.html + """ + event = 'subscribe_scan' + scene_id = StringField('EventKey') + ticket = StringField('Ticket') + + +@register_event('scan') +class ScanEvent(BaseEvent): + """ + 用户扫描二维码事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/2/5baf56ce4947d35003b86a9805634b1e.html + """ + event = 'scan' + scene_id = StringField('EventKey') + ticket = StringField('Ticket') + + +@register_event('location') +class LocationEvent(BaseEvent): + """ + 上报地理位置事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/2/5baf56ce4947d35003b86a9805634b1e.html + """ + event = 'location' + latitude = FloatField('Latitude', 0.0) + longitude = FloatField('Longitude', 0.0) + precision = FloatField('Precision', 0.0) + + +@register_event('click') +class ClickEvent(BaseEvent): + """ + 点击菜单拉取消息事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/2/5baf56ce4947d35003b86a9805634b1e.html + """ + event = 'click' + key = StringField('EventKey') + + +@register_event('xml') +class ViewEvent(BaseEvent): + """ + 点击菜单跳转链接事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/2/5baf56ce4947d35003b86a9805634b1e.html + """ + event = 'xml' + url = StringField('EventKey') + + +@register_event('masssendjobfinish') +class MassSendJobFinishEvent(BaseEvent): + """ + 群发消息任务完成事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/15/5380a4e6f02f2ffdc7981a8ed7a40753.html + """ + id = IntegerField('MsgID', 0) + event = 'masssendjobfinish' + status = StringField('Status') + total_count = IntegerField('TotalCount', 0) + filter_count = IntegerField('FilterCount', 0) + sent_count = IntegerField('SentCount', 0) + error_count = IntegerField('ErrorCount', 0) + + +@register_event('templatesendjobfinish') +class TemplateSendJobFinishEvent(BaseEvent): + """ + 模板消息任务完成事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/17/304c1885ea66dbedf7dc170d84999a9d.html + """ + id = IntegerField('MsgID') + event = 'templatesendjobfinish' + status = StringField('Status') + + +class BaseScanCodeEvent(BaseEvent): + key = StringField('EventKey') + scan_code_info = BaseField('ScanCodeInfo', {}) + + @property + def scan_type(self): + return self.scan_code_info['ScanType'] + + @property + def scan_result(self): + return self.scan_code_info['ScanResult'] + + +@register_event('scancode_push') +class ScanCodePushEvent(BaseScanCodeEvent): + """ + 扫码推事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/9/981d772286d10d153a3dc4286c1ee5b5.html + """ + event = 'scancode_push' + + +@register_event('scancode_waitmsg') +class ScanCodeWaitMsgEvent(BaseScanCodeEvent): + """ + 扫码推事件且弹出“消息接收中”提示框的事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/9/981d772286d10d153a3dc4286c1ee5b5.html + """ + event = 'scancode_waitmsg' + + +class BasePictureEvent(BaseEvent): + key = StringField('EventKey') + pictures_info = BaseField('SendPicsInfo', {}) + + @property + def count(self): + return int(self.pictures_info['Count']) + + @property + def pictures(self): + if self.pictures_info['PicList']: + items = self.pictures_info['PicList']['item'] + if self.count > 1: + return items + return [items] + return [] + + +@register_event('pic_sysphoto') +class PicSysPhotoEvent(BasePictureEvent): + """ + 弹出系统拍照发图的事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/9/981d772286d10d153a3dc4286c1ee5b5.html + """ + event = 'pic_sysphoto' + + +@register_event('pic_photo_or_album') +class PicPhotoOrAlbumEvent(BasePictureEvent): + """ + 弹出拍照或者相册发图的事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/9/981d772286d10d153a3dc4286c1ee5b5.html + """ + event = 'pic_photo_or_album' + + +@register_event('pic_weixin') +class PicWeChatEvent(BasePictureEvent): + """ + 弹出微信相册发图器的事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/9/981d772286d10d153a3dc4286c1ee5b5.html + """ + event = 'pic_weixin' + + +@register_event('location_select') +class LocationSelectEvent(BaseEvent): + """ + 弹出地理位置选择器的事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/9/981d772286d10d153a3dc4286c1ee5b5.html + """ + event = 'location_select' + key = StringField('EventKey') + location_info = BaseField('SendLocationInfo', {}) + + @property + def location_x(self): + return self.location_info['Location_X'] + + @property + def location_y(self): + return self.location_info['Location_Y'] + + @property + def location(self): + return self.location_x, self.location_y + + @property + def scale(self): + return self.location_info['Scale'] + + @property + def label(self): + return self.location_info['Label'] + + @property + def poiname(self): + return self.location_info['Poiname'] + + +@register_event('card_pass_check') +class CardPassCheckEvent(BaseEvent): + event = 'card_pass_check' + card_id = StringField('CardId') + + +@register_event('card_not_pass_check') +class CardNotPassCheckEvent(BaseEvent): + event = 'card_not_pass_check' + card_id = StringField('CardId') + + +@register_event('user_get_card') +class UserGetCardEvent(BaseEvent): + """ + 领取事件推送 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/16/28b34ee91675a04cb24853768debded4.html#.E9.A2.86.E5.8F.96.E4.BA.8B.E4.BB.B6.E6.8E.A8.E9.80.81 + """ + event = 'user_get_card' + card_id = StringField('CardId') + is_given_by_friend = IntegerField('IsGiveByFriend') + friend = StringField('FriendUserName') + code = StringField('UserCardCode') + old_code = StringField('OldUserCardCode') + outer_id = StringField('OuterId') + + +@register_event('user_del_card') +class UserDeleteCardEvent(BaseEvent): + """ + 卡券删除事件推送 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/16/28b34ee91675a04cb24853768debded4.html#.E5.88.A0.E9.99.A4.E4.BA.8B.E4.BB.B6.E6.8E.A8.E9.80.81 + """ + event = 'user_del_card' + card_id = StringField('CardId') + code = StringField('UserCardCode') + + +@register_event('user_consume_card') +class UserConsumeCardEvent(BaseEvent): + """ + 卡券核销事件推送 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/16/28b34ee91675a04cb24853768debded4.html#.E6.A0.B8.E9.94.80.E4.BA.8B.E4.BB.B6.E6.8E.A8.E9.80.81 + """ + event = 'user_consume_card' + card_id = StringField('CardId') + code = StringField('UserCardCode') + consume_source = StringField('ConsumeSource') + location_id = StringField('LocationId') + staff = StringField('StaffOpenId') + + +@register_event('merchant_order') +class MerchantOrderEvent(BaseEvent): + event = 'merchant_order' + order_id = StringField('OrderId') + order_status = IntegerField('OrderStatus') + product_id = StringField('ProductId') + sku_info = StringField('SkuInfo') + + +@register_event('kf_create_session') +class KfCreateSessionEvent(BaseEvent): + event = 'kf_create_session' + account = StringField('KfAccount') + + +@register_event('kf_close_session') +class KfCloseSessionEvent(BaseEvent): + event = 'kf_close_session' + account = StringField('KfAccount') + + +@register_event('kf_switch_session') +class KfSwitchSessionEvent(BaseEvent): + event = 'kf_switch_session' + from_account = StringField('FromKfAccount') + to_account = StringField('ToKfAccount') + + +@register_event('device_text') +class DeviceTextEvent(BaseEvent): + event = 'device_text' + device_type = StringField('DeviceType') + device_id = StringField('DeviceID') + session_id = StringField('SessionID') + content = Base64DecodeField('Content') + + +@register_event('device_bind') +class DeviceBindEvent(BaseEvent): + event = 'bind' + device_type = StringField('DeviceType') + device_id = StringField('DeviceID') + session_id = StringField('SessionID') + content = Base64DecodeField('Content') + open_id = StringField('OpenID') + + +@register_event('device_unbind') +class DeviceUnbindEvent(BaseEvent): + event = 'unbind' + device_type = StringField('DeviceType') + device_id = StringField('DeviceID') + session_id = StringField('SessionID') + content = Base64DecodeField('Content') + open_id = StringField('OpenID') + + +@register_event('device_subscribe_status') +class DeviceSubscribeStatusEvent(BaseEvent): + event = 'subscribe_status' + device_type = StringField('DeviceType') + device_id = StringField('DeviceID') + open_id = StringField('OpenID') + op_type = IntegerField('OpType') + + +@register_event('device_unsubscribe_status') +class DeviceUnsubscribeStatusEvent(BaseEvent): + event = 'subscribe_status' + device_type = StringField('DeviceType') + device_id = StringField('DeviceID') + open_id = StringField('OpenID') + op_type = IntegerField('OpType') + + +@register_event('shakearoundusershake') +class ShakearoundUserShakeEvent(BaseEvent): + event = 'shakearound_user_shake' + _chosen_beacon = BaseField('ChosenBeacon', {}) + _around_beacons = BaseField('AroundBeacons', {}) + + @property + def chosen_beacon(self): + beacon = self._chosen_beacon + if not beacon: + return {} + return { + 'uuid': beacon['Uuid'], + 'major': beacon['Major'], + 'minor': beacon['Minor'], + 'distance': float(beacon['Distance']), + } + + @property + def around_beacons(self): + beacons = self._around_beacons + if not beacons: + return [] + + ret = [] + for beacon in beacons['AroundBeacon']: + ret.append({ + 'uuid': beacon['Uuid'], + 'major': beacon['Major'], + 'minor': beacon['Minor'], + 'distance': float(beacon['Distance']), + }) + return ret + + +@register_event('poi_check_notify') +class PoiCheckNotifyEvent(BaseEvent): + event = 'poi_check_notify' + poi_id = StringField('PoiId') + uniq_id = StringField('UniqId') + result = StringField('Result') + message = StringField('Msg') + + +@register_event('wificonnected') +class WiFiConnectedEvent(BaseEvent): + event = 'wificconnected' + connect_time = IntegerField('ConnectTime') + expire_time = IntegerField('ExpireTime') + vendor_id = StringField('VendorId') + shop_id = StringField('PlaceId') + bssid = StringField('DeviceNo') + + +# ============================================================================ +# 微信认证事件推送 +# ============================================================================ +@register_event('qualification_verify_success') +class QualificationVerifySuccessEvent(BaseEvent): + """ + 资质认证成功事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/1/7f81dec16b801b34629091094c099439.html + """ + event = 'qualification_verify_success' + expired_time = DateTimeField('ExpiredTime') + + +@register_event('qualification_verify_fail') +class QualificationVerifyFailEvent(BaseEvent): + """ + 资质认证失败事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/1/7f81dec16b801b34629091094c099439.html + """ + event = 'qualification_verify_fail' + fail_time = DateTimeField('FailTime') + fail_reason = StringField('FailReason') + + +@register_event('naming_verify_success') +class NamingVerifySuccessEvent(BaseEvent): + """ + 名称认证成功事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/1/7f81dec16b801b34629091094c099439.html + """ + event = 'naming_verify_success' + expired_time = DateTimeField('ExpiredTime') + + +@register_event('naming_verify_fail') +class NamingVerifyFailEvent(BaseEvent): + """ + 名称认证失败事件 + + 客户端不打勾,但仍有接口权限。详情请参阅 + http://mp.weixin.qq.com/wiki/1/7f81dec16b801b34629091094c099439.html + """ + event = 'naming_verify_fail' + fail_time = DateTimeField('FailTime') + fail_reason = StringField('FailReason') + + +@register_event('annual_renew') +class AnnualRenewEvent(BaseEvent): + """ + 年审通知事件 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/1/7f81dec16b801b34629091094c099439.html + """ + event = 'annual_renew' + expired_time = DateTimeField('ExpiredTime') + + +@register_event('verify_expired') +class VerifyExpiredEvent(BaseEvent): + """ + 认证过期失效通知 + + 详情请参阅 + http://mp.weixin.qq.com/wiki/1/7f81dec16b801b34629091094c099439.html + """ + event = 'verify_expired' + expired_time = DateTimeField('ExpiredTime') + + +@register_event('user_scan_product') +class UserScanProductEvent(BaseEvent): + """ + 打开商品主页事件 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/f4109a5e44b4bfbc7eb1337eb739f3e3.html + """ + event = 'user_scan_product' + standard = StringField('KeyStandard') + key = StringField('KeyStr') + country = StringField('Country') + province = StringField('Province') + city = StringField('City') + sex = IntegerField('Sex') + scene = IntegerField('Scene') + + +@register_event('user_scan_product_enter_session') +class UserScanProductEnterSessionEvent(BaseEvent): + """ + 进入公众号事件 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/f4109a5e44b4bfbc7eb1337eb739f3e3.html + """ + event = 'user_scan_product_enter_session' + standard = StringField('KeyStandard') + key = StringField('KeyStr') + + +@register_event('user_scan_product_async') +class UserScanProductAsyncEvent(BaseEvent): + """ + 地理位置信息异步推送事件 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/f4109a5e44b4bfbc7eb1337eb739f3e3.html + """ + event = 'user_scan_product_async' + standard = StringField('KeyStandard') + key = StringField('KeyStr') + region_code = StringField('RegionCode') + + +@register_event('user_scan_product_verify_action') +class UserScanProductVerifyActionEvent(BaseEvent): + """ + 商品审核结果事件 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/f4109a5e44b4bfbc7eb1337eb739f3e3.html + """ + event = 'user_scan_product_verify_action' + standard = StringField('KeyStandard') + key = StringField('KeyStr') + result = StringField('Result') + reason = StringField('ReasonMsg') + + +@register_event('subscribe_scan_product') +class SubscribeScanProductEvent(BaseEvent): + """ + 用户在商品主页中关注公众号事件 + + 详情请参考 + http://mp.weixin.qq.com/wiki/15/f4109a5e44b4bfbc7eb1337eb739f3e3.html + """ + event = 'subscribe_scan_product' + event_key = StringField('EventKey') + + @property + def scene(self): + return self.event_key.split('|', 1)[0] + + @property + def standard(self): + return self.event_key.split('|')[1] + + @property + def key(self): + return self.event_key.split('|')[2] diff --git a/sg_wechat_enterprise/we_api/exceptions.py b/sg_wechat_enterprise/we_api/exceptions.py new file mode 100644 index 00000000..891079c8 --- /dev/null +++ b/sg_wechat_enterprise/we_api/exceptions.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- +""" + wechatpy.exceptions + ~~~~~~~~~~~~~~~~~~~~ + + Basic exceptions definition. + + :copyright: (c) 2014 by messense. + :license: MIT, see LICENSE for more details. +""" +from __future__ import absolute_import, unicode_literals +import six + +from wechatpy.utils import to_binary, to_text + + +class WeChatException(Exception): + """Base exception for wechatpy""" + + def __init__(self, errcode, errmsg): + """ + :param errcode: Error code + :param errmsg: Error message + """ + self.errcode = errcode + self.errmsg = errmsg + + def __str__(self): + if six.PY2: + return to_binary('Error code: {code}, message: {msg}'.format( + code=self.errcode, + msg=self.errmsg + )) + else: + return to_text('Error code: {code}, message: {msg}'.format( + code=self.errcode, + msg=self.errmsg + )) + + def __repr__(self): + _repr = '{klass}({code}, {msg}'.format( + klass=self.__class__.__name__, + code=self.errcode, + msg=self.errmsg + ) + if six.PY2: + return to_binary(_repr) + else: + return to_text(_repr) + + +class WeChatClientException(WeChatException): + """WeChat API client exception class""" + def __init__(self, errcode, errmsg, client=None, + request=None, response=None): + super(WeChatClientException, self).__init__(errcode, errmsg) + self.client = client + self.request = request + self.response = response + + +class InvalidSignatureException(WeChatException): + """Invalid signature exception class""" + + def __init__(self, errcode=-40001, errmsg='Invalid signature'): + super(InvalidSignatureException, self).__init__(errcode, errmsg) + + +class APILimitedException(WeChatClientException): + """WeChat API call limited exception class""" + pass + + +class InvalidAppIdException(WeChatException): + """Invalid app_id exception class""" + + def __init__(self, errcode=-40005, errmsg='Invalid AppId'): + super(InvalidAppIdException, self).__init__(errcode, errmsg) + + +class WeChatOAuthException(WeChatClientException): + """WeChat OAuth API exception class""" + pass + + +class WeChatPayException(WeChatClientException): + """WeChat Pay API exception class""" + def __init__(self, return_code, result_code=None, return_msg=None, + errcode=None, errmsg=None, client=None, + request=None, response=None): + """ + :param return_code: 返回状态码 + :param result_code: 业务结果 + :param return_msg: 返回信息 + :param errcode: 错误代码 + :param errmsg: 错误代码描述 + """ + super(WeChatPayException, self).__init__( + errcode, + errmsg, + client, + request, + response + ) + self.return_code = return_code + self.result_code = result_code + self.return_msg = return_msg + + def __str__(self): + if six.PY2: + return to_binary('Error code: {code}, message: {msg}'.format( + code=self.return_code, + msg=self.return_msg + )) + else: + return to_text('Error code: {code}, message: {msg}'.format( + code=self.return_code, + msg=self.return_msg + )) + + def __repr__(self): + _repr = '{klass}({code}, {msg})'.format( + klass=self.__class__.__name__, + code=self.return_code, + msg=self.return_msg + ) + if six.PY2: + return to_binary(_repr) + else: + return to_text(_repr) diff --git a/sg_wechat_enterprise/we_api/fields.py b/sg_wechat_enterprise/we_api/fields.py new file mode 100644 index 00000000..3797a6a3 --- /dev/null +++ b/sg_wechat_enterprise/we_api/fields.py @@ -0,0 +1,237 @@ +# -*- coding: utf-8 -*- +""" + wechatpy.fields + ~~~~~~~~~~~~~~~~ + + This module defines some useful field types for parse WeChat messages + + :copyright: (c) 2014 by messense. + :license: MIT, see LICENSE for more details. +""" +from __future__ import absolute_import, unicode_literals +import time +from datetime import datetime +import base64 +import copy + +import six + +from wechatpy.utils import to_text, to_binary, ObjectDict, timezone + +default_timezone = timezone('Asia/Shanghai') + + +class FieldDescriptor(object): + def __init__(self, field): + self.field = field + self.attr_name = field.name + + def __get__(self, instance, instance_type=None): + if instance is not None: + value = instance._data.get(self.attr_name) + if value is None: + value = copy.deepcopy(self.field.default) + instance._data[self.attr_name] = value + if isinstance(value, dict): + value = ObjectDict(value) + if value and not isinstance(value, (dict, list, tuple)) and \ + six.callable(self.field.converter): + value = self.field.converter(value) + return value + return self.field + + def __set__(self, instance, value): + instance._data[self.attr_name] = value + + +class BaseField(object): + converter = None + + def __init__(self, name, default=None): + self.name = name + self.default = default + + def to_xml(self, value): + raise NotImplementedError() + + def __repr__(self): + _repr = '{klass}({name})'.format( + klass=self.__class__.__name__, + name=repr(self.name) + ) + if six.PY2: + return to_binary(_repr) + else: + return to_text(_repr) + + def add_to_class(self, klass, name): + self.klass = klass + klass._fields[name] = self + setattr(klass, name, FieldDescriptor(self)) + + +class StringField(BaseField): + def __to_text(self, value): + return to_text(value) + + converter = __to_text + + def to_xml(self, value): + value = self.converter(value) + tpl = '<{name}>' + return tpl.format(name=self.name, value=value) + + +class IntegerField(BaseField): + converter = int + + def to_xml(self, value): + value = self.converter(value) if value is not None else self.default + tpl = '<{name}>{value}' + return tpl.format(name=self.name, value=value) + + +class DateTimeField(BaseField): + def __converter(self, value): + v = int(value) + return datetime.fromtimestamp(v, tz=default_timezone) + + converter = __converter + + def to_xml(self, value): + value = time.mktime(datetime.timetuple(value)) + value = int(value) + tpl = '<{name}>{value}' + return tpl.format(name=self.name, value=value) + + +class FloatField(BaseField): + converter = float + + def to_xml(self, value): + value = self.converter(value) if value is not None else self.default + tpl = '<{name}>{value}' + return tpl.format(name=self.name, value=value) + + +class ImageField(StringField): + def to_xml(self, value): + value = self.converter(value) + tpl = """ + + """ + return tpl.format(value=value) + + +class VoiceField(StringField): + def to_xml(self, value): + value = self.converter(value) + tpl = """ + + """ + return tpl.format(value=value) + + +class VideoField(StringField): + def to_xml(self, value): + media_id = self.converter(value['media_id']) + if 'title' in value: + title = self.converter(value['title']) + if 'description' in value: + description = self.converter(value['description']) + tpl = """""" + return tpl.format( + media_id=media_id, + title=title, + description=description + ) + + +class MusicField(StringField): + def to_xml(self, value): + thumb_media_id = self.converter(value['thumb_media_id']) + if 'title' in value: + title = self.converter(value['title']) + if 'description' in value: + description = self.converter(value['description']) + if 'music_url' in value: + music_url = self.converter(value['music_url']) + if 'hq_music_url' in value: + hq_music_url = self.converter(value['hq_music_url']) + tpl = """ + + <![CDATA[{title}]]> + + + + """ + return tpl.format( + thumb_media_id=thumb_media_id, + title=title, + description=description, + music_url=music_url, + hq_music_url=hq_music_url + ) + + +class ArticlesField(StringField): + def to_xml(self, articles): + article_count = len(articles) + items = [] + for article in articles: + title = self.converter(article.get('title', '')) + description = self.converter(article.get('description', '')) + image = self.converter(article.get('image', '')) + url = self.converter(article.get('url', '')) + item_tpl = """ + <![CDATA[{title}]]> + + + + """ + item = item_tpl.format( + title=title, + description=description, + image=image, + url=url + ) + items.append(item) + items_str = '\n'.join(items) + tpl = """{article_count} + {items}""" + return tpl.format( + article_count=article_count, + items=items_str + ) + + +class Base64EncodeField(StringField): + def __base64_encode(self, text): + return to_text(base64.b64encode(to_binary(text))) + + converter = __base64_encode + + +class Base64DecodeField(StringField): + def __base64_decode(self, text): + return to_text(base64.b64decode(to_binary(text))) + + converter = __base64_decode + + +class HardwareField(StringField): + def to_xml(self, value=None): + value = value or {'xml': 'myrank', 'action': 'ranklist'} + tpl = """<{name}> + + + """ + return tpl.format( + name=self.name, + view=value.get('xml'), + action=value.get('action') + ) diff --git a/sg_wechat_enterprise/we_api/messages.py b/sg_wechat_enterprise/we_api/messages.py new file mode 100644 index 00000000..b53c6d1b --- /dev/null +++ b/sg_wechat_enterprise/we_api/messages.py @@ -0,0 +1,173 @@ +# -*- coding: utf-8 -*- +""" + wechatpy.messages + ~~~~~~~~~~~~~~~~~~ + + This module defines all the messages you can get from WeChat server + + :copyright: (c) 2014 by messense. + :license: MIT, see LICENSE for more details. +""" +from __future__ import absolute_import, unicode_literals +import copy +import six + +from wechatpy.fields import ( + BaseField, + StringField, + IntegerField, + DateTimeField, + FieldDescriptor +) +from wechatpy.utils import to_text, to_binary + + +MESSAGE_TYPES = {} + + +def register_message(msg_type): + def register(cls): + MESSAGE_TYPES[msg_type] = cls + return cls + return register + + +class MessageMetaClass(type): + """Metaclass for all messages""" + def __new__(cls, name, bases, attrs): + for b in bases: + if not hasattr(b, '_fields'): + continue + + for k, v in b.__dict__.items(): + if k in attrs: + continue + if isinstance(v, FieldDescriptor): + attrs[k] = copy.deepcopy(v.field) + + cls = super(MessageMetaClass, cls).__new__(cls, name, bases, attrs) + cls._fields = {} + + for name, field in cls.__dict__.items(): + if isinstance(field, BaseField): + field.add_to_class(cls, name) + return cls + + +class BaseMessage(six.with_metaclass(MessageMetaClass)): + """Base class for all messages and events""" + type = 'unknown' + id = IntegerField('MsgId', 0) + source = StringField('FromUserName') + target = StringField('ToUserName') + create_time = DateTimeField('CreateTime') + time = IntegerField('CreateTime') + + def __init__(self, message): + self._data = message + + def __repr__(self): + _repr = "{klass}({msg})".format( + klass=self.__class__.__name__, + msg=repr(self._data) + ) + if six.PY2: + return to_binary(_repr) + else: + return to_text(_repr) + + +@register_message('text') +class TextMessage(BaseMessage): + """ + 文本消息 + 详情请参阅 + http://mp.weixin.qq.com/wiki/10/79502792eef98d6e0c6e1739da387346.html + """ + type = 'text' + content = StringField('Content') + + +@register_message('image') +class ImageMessage(BaseMessage): + """ + 图片消息 + 详情请参阅 + http://mp.weixin.qq.com/wiki/10/79502792eef98d6e0c6e1739da387346.html + """ + type = 'image' + media_id = StringField('MediaId') + image = StringField('PicUrl') + + +@register_message('voice') +class VoiceMessage(BaseMessage): + """ + 语音消息 + 详情请参阅 + http://mp.weixin.qq.com/wiki/10/79502792eef98d6e0c6e1739da387346.html + """ + type = 'voice' + media_id = StringField('MediaId') + format = StringField('Format') + recognition = StringField('Recognition') + + +@register_message('shortvideo') +class ShortVideoMessage(BaseMessage): + """ + 短视频消息 + 详情请参阅 + http://mp.weixin.qq.com/wiki/10/79502792eef98d6e0c6e1739da387346.html + """ + type = 'shortvideo' + media_id = StringField('MediaId') + thumb_media_id = StringField('ThumbMediaId') + + +@register_message('video') +class VideoMessage(BaseMessage): + """ + 视频消息 + 详情请参阅 + http://mp.weixin.qq.com/wiki/10/79502792eef98d6e0c6e1739da387346.html + """ + type = 'video' + media_id = StringField('MediaId') + thumb_media_id = StringField('ThumbMediaId') + + +@register_message('location') +class LocationMessage(BaseMessage): + """ + 地理位置消息 + 详情请参阅 + http://mp.weixin.qq.com/wiki/10/79502792eef98d6e0c6e1739da387346.html + """ + type = 'location' + location_x = StringField('Location_X') + location_y = StringField('Location_Y') + scale = StringField('Scale') + label = StringField('Label') + + @property + def location(self): + return self.location_x, self.location_y + + +@register_message('link') +class LinkMessage(BaseMessage): + """ + 链接消息 + 详情请参阅 + http://mp.weixin.qq.com/wiki/10/79502792eef98d6e0c6e1739da387346.html + """ + type = 'link' + title = StringField('Title') + description = StringField('Description') + url = StringField('Url') + + +class UnknownMessage(BaseMessage): + """未知消息类型""" + pass diff --git a/sg_wechat_enterprise/we_api/oauth/__init__.py b/sg_wechat_enterprise/we_api/oauth/__init__.py new file mode 100644 index 00000000..e58aef38 --- /dev/null +++ b/sg_wechat_enterprise/we_api/oauth/__init__.py @@ -0,0 +1,219 @@ +# -*- coding: utf-8 -*- +""" + wechatpy.oauth + ~~~~~~~~~~~~~~~ + + This module provides OAuth2 library for WeChat + + :copyright: (c) 2014 by messense. + :license: MIT, see LICENSE for more details. +""" +from __future__ import absolute_import, unicode_literals +try: + from pkgutil import extend_path + __path__ = extend_path(__path__, __name__) +except ImportError: + from pkg_resources import declare_namespace + declare_namespace(__name__) + +import requests +from six.moves.urllib.parse import quote +# from urllib import quote +from wechatpy.utils import json +from wechatpy.exceptions import WeChatOAuthException + + +class WeChatOAuth(object): + """微信公众平台 OAuth 网页授权 """ + + API_BASE_URL = 'https://api.weixin.qq.com/' + OAUTH_BASE_URL = 'https://open.weixin.qq.com/connect/' + + def __init__(self, app_id, secret, redirect_uri, + scope='snsapi_base', state=''): + """ + + :param app_id: 微信公众号 app_id + :param secret: 微信公众号 secret + :param redirect_uri: OAuth2 redirect URI + :param scope: 可选,微信公众号 OAuth2 scope,默认为 ``snsapi_base`` + :param state: 可选,微信公众号 OAuth2 state + """ + self.app_id = app_id + self.secret = secret + self.redirect_uri = redirect_uri + self.scope = scope + self.state = state + + def _request(self, method, url_or_endpoint, **kwargs): + if not url_or_endpoint.startswith(('http://', 'https://')): + url = '{base}{endpoint}'.format( + base=self.API_BASE_URL, + endpoint=url_or_endpoint + ) + else: + url = url_or_endpoint + + if isinstance(kwargs.get('data', ''), dict): + body = json.dumps(kwargs['data'], ensure_ascii=False) + body = body.encode('utf-8') + kwargs['data'] = body + + res = requests.request( + method=method, + url=url, + **kwargs + ) + try: + res.raise_for_status() + except requests.RequestException as reqe: + raise WeChatOAuthException( + errcode=None, + errmsg=None, + client=self, + request=reqe.request, + response=reqe.response + ) + result = json.loads(res.content.decode('utf-8', 'ignore'), strict=False) + + if 'errcode' in result and result['errcode'] != 0: + errcode = result['errcode'] + errmsg = result['errmsg'] + raise WeChatOAuthException( + errcode, + errmsg, + client=self, + request=res.request, + response=res + ) + + return result + + def _get(self, url, **kwargs): + return self._request( + method='get', + url_or_endpoint=url, + **kwargs + ) + + @property + def authorize_url(self): + """获取授权跳转地址 + + :return: URL 地址 + """ + redirect_uri = quote(self.redirect_uri) + url_list = [ + self.OAUTH_BASE_URL, + 'oauth2/authorize?appid=', + self.app_id, + '&redirect_uri=', + redirect_uri, + '&response_type=code&scope=', + self.scope + ] + if self.state: + url_list.extend(['&state=', self.state]) + url_list.append('#wechat_redirect') + return ''.join(url_list) + + @property + def qrconnect_url(self): + """生成扫码登录地址 + + :return: URL 地址 + """ + redirect_uri = quote(self.redirect_uri) + url_list = [ + self.OAUTH_BASE_URL, + 'qrconnect?appid=', + self.app_id, + '&redirect_uri=', + redirect_uri, + '&response_type=code&scope=', + 'snsapi_login' # scope + ] + if self.state: + url_list.extend(['&state=', self.state]) + url_list.append('#wechat_redirect') + return ''.join(url_list) + + def fetch_access_token(self, code): + """获取 access_token + + :param code: 授权完成跳转回来后 URL 中的 code 参数 + :return: JSON 数据包 1 + """ + res = self._get( + 'sns/oauth2/access_token', + params={ + 'appid': self.app_id, + 'secret': self.secret, + 'code': code, + 'grant_type': 'authorization_code' + } + ) + self.access_token = res['access_token'] + self.open_id = res['openid'] + self.refresh_token = res['refresh_token'] + self.expires_in = res['expires_in'] + return res + + def refresh_access_token(self, refresh_token): + """刷新 access token + + :param refresh_token: OAuth2 refresh token + :return: JSON 数据包 + """ + res = self._get( + 'sns/oauth2/refresh_token', + params={ + 'appid': self.app_id, + 'grant_type': 'refresh_token', + 'refresh_token': refresh_token + } + ) + self.access_token = res['access_token'] + self.open_id = res['openid'] + self.refresh_token = res['refresh_token'] + self.expires_in = res['expires_in'] + return res + + def get_user_info(self, openid=None, access_token=None, lang='zh_CN'): + """获取用户信息 + + :param openid: 可选,微信 openid,默认获取当前授权用户信息 + :param access_token: 可选,access_token,默认使用当前授权用户的 access_token + :param lang: 可选,语言偏好, 默认为 ``zh_CN`` + :return: JSON 数据包 + """ + openid = openid or self.open_id + access_token = access_token or self.access_token + return self._get( + 'sns/userinfo', + params={ + 'access_token': access_token, + 'openid': openid, + 'lang': lang + } + ) + + def check_access_token(self, openid=None, access_token=None): + """检查 access_token 有效性 + + :param openid: 可选,微信 openid,默认获取当前授权用户信息 + :param access_token: 可选,access_token,默认使用当前授权用户的 access_token + :return: 有效返回 True,否则 False + """ + openid = openid or self.open_id + access_token = access_token or self.access_token + res = self._get( + 'sns/auth', + params={ + 'access_token': access_token, + 'openid': openid + } + ) + if res['errcode'] == 0: + return True + return False diff --git a/sg_wechat_enterprise/we_api/parser.py b/sg_wechat_enterprise/we_api/parser.py new file mode 100644 index 00000000..c9e4ab76 --- /dev/null +++ b/sg_wechat_enterprise/we_api/parser.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +""" + wechatpy.parser + ~~~~~~~~~~~~~~~~ + This module provides functions for parsing WeChat messages + + :copyright: (c) 2014 by messense. + :license: MIT, see LICENSE for more details. +""" +from __future__ import absolute_import, unicode_literals +import xmltodict + +from wechatpy.messages import MESSAGE_TYPES, UnknownMessage +from wechatpy.events import EVENT_TYPES +from wechatpy.utils import to_text + + +def parse_message(xml): + """ + 解析微信服务器推送的 XML 消息 + + :param xml: XML 消息 + :return: 解析成功返回对应的消息或事件,否则返回 ``UnknownMessage`` + """ + if not xml: + return + message = xmltodict.parse(to_text(xml))['xml'] + message_type = message['MsgType'].lower() + if message_type in ('event', 'device_event'): + event_type = message['Event'].lower() + # special event type for device_event + if message_type == 'device_event': + event_type = 'device_{event}'.format(event=event_type) + if event_type == 'subscribe' and message.get('EventKey'): + event_key = message['EventKey'] + if event_key.startswith(('scanbarcode|', 'scanimage|')): + event_type = 'subscribe_scan_product' + message['Event'] = event_type + else: + # Scan to subscribe with scene id event + event_type = 'subscribe_scan' + message['Event'] = event_type + message['EventKey'] = event_key.replace('qrscene_', '') + message_class = EVENT_TYPES.get(event_type, UnknownMessage) + else: + message_class = MESSAGE_TYPES.get(message_type, UnknownMessage) + return message_class(message) diff --git a/sg_wechat_enterprise/we_api/pay/__init__.py b/sg_wechat_enterprise/we_api/pay/__init__.py new file mode 100644 index 00000000..c88a5fc3 --- /dev/null +++ b/sg_wechat_enterprise/we_api/pay/__init__.py @@ -0,0 +1,199 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import sys +import inspect +import logging + +import requests +import xmltodict +from xml.parsers.expat import ExpatError +from optionaldict import optionaldict + +from wechatpy.utils import random_string +from wechatpy.exceptions import WeChatPayException, InvalidSignatureException +from wechatpy.pay.utils import ( + calculate_signature, _check_signature, dict_to_xml +) +from wechatpy.pay.base import BaseWeChatPayAPI +from wechatpy.pay import api + + +logger = logging.getLogger(__name__) + + +def _is_api_endpoint(obj): + return isinstance(obj, BaseWeChatPayAPI) + + +class WeChatPay(object): + """ + 微信红包接口 + + :param appid: 微信公众号 appid + :param api_key: 商户 key + :param mch_id: 商户号 + :param sub_mch_id: 可选,子商户号,受理模式下必填 + :param mch_cert: 必填,商户证书路径 + :param mch_key: 必填,商户证书私钥路径 + """ + redpack = api.WeChatRedpack() + """红包接口""" + transfer = api.WeChatTransfer() + """企业付款接口""" + coupon = api.WeChatCoupon() + """代金券接口""" + order = api.WeChatOrder() + """订单接口""" + refund = api.WeChatRefund() + """退款接口""" + micropay = api.WeChatMicroPay() + """刷卡支付接口""" + tools = api.WeChatTools() + """工具类接口""" + jsapi = api.WeChatJSAPI() + + API_BASE_URL = 'https://api.mch.weixin.qq.com/' + + def __new__(cls, *args, **kwargs): + self = super(WeChatPay, cls).__new__(cls) + if sys.version_info[:2] == (2, 6): + import copy + # Python 2.6 inspect.gemembers bug workaround + # http://bugs.python.org/issue1785 + for name, _api in self.__class__.__dict__.items(): + if isinstance(_api, BaseWeChatPayAPI): + _api = copy.deepcopy(_api) + _api._client = self + setattr(self, name, _api) + else: + api_endpoints = inspect.getmembers(self, _is_api_endpoint) + for name, _api in api_endpoints: + api_cls = type(_api) + _api = api_cls(self) + setattr(self, name, _api) + return self + + def __init__(self, appid, api_key, mch_id, sub_mch_id=None, + mch_cert=None, mch_key=None): + """ + :param appid: 微信公众号 appid + :param api_key: 商户 key + :param mch_id: 商户号 + :param sub_mch_id: 可选,子商户号,受理模式下必填 + :param mch_cert: 商户证书路径 + :param mch_key: 商户证书私钥路径 + """ + self.appid = appid + self.api_key = api_key + self.mch_id = mch_id + self.sub_mch_id = sub_mch_id + self.mch_cert = mch_cert + self.mch_key = mch_key + + def _request(self, method, url_or_endpoint, **kwargs): + if not url_or_endpoint.startswith(('http://', 'https://')): + api_base_url = kwargs.pop('api_base_url', self.API_BASE_URL) + url = '{base}{endpoint}'.format( + base=api_base_url, + endpoint=url_or_endpoint + ) + else: + url = url_or_endpoint + + if isinstance(kwargs.get('data', ''), dict): + data = optionaldict(kwargs['data']) + if 'mchid' not in data: + # Fuck Tencent + data.setdefault('mch_id', self.mch_id) + data.setdefault('sub_mch_id', self.sub_mch_id) + data.setdefault('nonce_str', random_string(32)) + sign = calculate_signature(data, self.api_key) + body = dict_to_xml(data, sign) + body = body.encode('utf-8') + kwargs['data'] = body + + # 商户证书 + if self.mch_cert and self.mch_key: + kwargs['cert'] = (self.mch_cert, self.mch_key) + + res = requests.request( + method=method, + url=url, + **kwargs + ) + try: + res.raise_for_status() + except requests.RequestException as reqe: + raise WeChatPayException( + return_code=None, + client=self, + request=reqe.request, + response=reqe.response + ) + + return self._handle_result(res) + + def _handle_result(self, res): + res.encoding = 'utf-8' + xml = res.text + try: + data = xmltodict.parse(xml)['xml'] + except (xmltodict.ParsingInterrupted, ExpatError): + # 解析 XML 失败 + logger.debug('WeChat payment result xml parsing error', exc_info=True) + return xml + + return_code = data['return_code'] + return_msg = data.get('return_msg') + result_code = data.get('result_code') + errcode = data.get('err_code') + errmsg = data.get('err_code_des') + if return_code != 'SUCCESS' or result_code != 'SUCCESS': + # 返回状态码不为成功 + raise WeChatPayException( + return_code, + result_code, + return_msg, + errcode, + errmsg, + client=self, + request=res.request, + response=res + ) + return data + + def get(self, url, **kwargs): + return self._request( + method='get', + url_or_endpoint=url, + **kwargs + ) + + def post(self, url, **kwargs): + return self._request( + method='post', + url_or_endpoint=url, + **kwargs + ) + + def check_signature(self, params): + return _check_signature(params, self.api_key) + + def parse_payment_result(self, xml): + """解析微信支付结果通知""" + try: + data = xmltodict.parse(xml) + except (xmltodict.ParsingInterrupted, ExpatError): + raise InvalidSignatureException() + + if not data or 'xml' not in data: + raise InvalidSignatureException() + + data = data['xml'] + sign = data.pop('sign', None) + real_sign = calculate_signature(data, self.api_key) + if sign != real_sign: + raise InvalidSignatureException() + + data['sign'] = sign + return data diff --git a/sg_wechat_enterprise/we_api/pay/api/__init__.py b/sg_wechat_enterprise/we_api/pay/api/__init__.py new file mode 100644 index 00000000..75e80fd8 --- /dev/null +++ b/sg_wechat_enterprise/we_api/pay/api/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.pay.api.redpack import WeChatRedpack # NOQA +from wechatpy.pay.api.transfer import WeChatTransfer # NOQA +from wechatpy.pay.api.coupon import WeChatCoupon # NOQA +from wechatpy.pay.api.order import WeChatOrder # NOQA +from wechatpy.pay.api.refund import WeChatRefund # NOQA +from wechatpy.pay.api.tools import WeChatTools # NOQA +from wechatpy.pay.api.jsapi import WeChatJSAPI # NOQA +from wechatpy.pay.api.micropay import WeChatMicroPay # NOQA diff --git a/sg_wechat_enterprise/we_api/pay/api/coupon.py b/sg_wechat_enterprise/we_api/pay/api/coupon.py new file mode 100644 index 00000000..6be5d5d6 --- /dev/null +++ b/sg_wechat_enterprise/we_api/pay/api/coupon.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import random +from datetime import datetime + +from wechatpy.pay.base import BaseWeChatPayAPI + + +class WeChatCoupon(BaseWeChatPayAPI): + + def send(self, user_id, stock_id, op_user_id=None, device_info=None, + out_trade_no=None): + """ + 发放代金券 + + :param user_id: 用户在公众号下的 openid + :param stock_id: 代金券批次 ID + :param op_user_id: 可选,操作员账号,默认为商户号 + :param device_info: 可选,微信支付分配的终端设备号 + :param out_trade_no: 可选,商户订单号,需保持唯一性,默认自动生成 + :return: 返回的结果信息 + """ + if not out_trade_no: + now = datetime.now() + out_trade_no = '{0}{1}{2}'.format( + self.mch_id, + now.strftime('%Y%m%d%H%M%S'), + random.randint(1000, 10000) + ) + data = { + 'appid': self.appid, + 'coupon_stock_id': stock_id, + 'openid': user_id, + 'openid_count': 1, + 'partner_trade_no': out_trade_no, + 'op_user_id': op_user_id, + 'device_info': device_info, + 'version': '1.0', + 'type': 'XML', + } + return self._post('mmpaymkttransfers/send_coupon', data=data) + + def query_stock(self, stock_id, op_user_id=None, device_info=None): + """ + 查询代金券批次 + + :param stock_id: 代金券批次 ID + :param op_user_id: 可选,操作员账号,默认为商户号 + :param device_info: 可选,微信支付分配的终端设备号 + :return: 返回的结果信息 + """ + data = { + 'appid': self.appid, + 'coupon_stock_id': stock_id, + 'op_user_id': op_user_id, + 'device_info': device_info, + 'version': '1.0', + 'type': 'XML', + } + return self._post('mmpaymkttransfers/query_coupon_stock', data=data) + + def query_coupon(self, coupon_id, user_id, + op_user_id=None, device_info=None): + """ + 查询代金券信息 + + :param coupon_id: 代金券 ID + :param user_id: 用户在公众号下的 openid + :param op_user_id: 可选,操作员账号,默认为商户号 + :param device_info: 可选,微信支付分配的终端设备号 + :return: 返回的结果信息 + """ + data = { + 'coupon_id': coupon_id, + 'openid': user_id, + 'appid': self.appid, + 'op_user_id': op_user_id, + 'device_info': device_info, + 'version': '1.0', + 'type': 'XML', + } + return self._post('promotion/query_coupon', data=data) diff --git a/sg_wechat_enterprise/we_api/pay/api/jsapi.py b/sg_wechat_enterprise/we_api/pay/api/jsapi.py new file mode 100644 index 00000000..6f3196c4 --- /dev/null +++ b/sg_wechat_enterprise/we_api/pay/api/jsapi.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import time + +from wechatpy.utils import random_string, to_text +from wechatpy.pay.base import BaseWeChatPayAPI +from wechatpy.pay.utils import calculate_signature + + +class WeChatJSAPI(BaseWeChatPayAPI): + + def get_jsapi_signature(self, prepay_id, timestamp=None, nonce_str=None): + """ + 获取 JSAPI 签名 + + :param prepay_id: 统一下单接口返回的 prepay_id 参数值 + :param timestamp: 可选,时间戳,默认为当前时间戳 + :param nonce_str: 可选,随机字符串,默认自动生成 + :return: 签名 + """ + data = { + 'appId': self.appid, + 'timeStamp': timestamp or to_text(int(time.time())), + 'nonceStr': nonce_str or random_string(32), + 'signType': 'MD5', + 'package': 'prepay_id={0}'.format(prepay_id), + } + return calculate_signature(data, self._client.api_key) + + def get_jsapi_params(self, prepay_id, timestamp=None, nonce_str=None): + """ + 获取 JSAPI 参数 + + :param prepay_id: 统一下单接口返回的 prepay_id 参数值 + :param timestamp: 可选,时间戳,默认为当前时间戳 + :param nonce_str: 可选,随机字符串,默认自动生成 + :return: 签名 + """ + data = { + 'appId': self.appid, + 'timeStamp': timestamp or to_text(int(time.time())), + 'nonceStr': nonce_str or random_string(32), + 'signType': 'MD5', + 'package': 'prepay_id={0}'.format(prepay_id), + } + sign = calculate_signature(data, self._client.api_key) + data['paySign'] = sign + return data diff --git a/sg_wechat_enterprise/we_api/pay/api/micropay.py b/sg_wechat_enterprise/we_api/pay/api/micropay.py new file mode 100644 index 00000000..5a77bf0d --- /dev/null +++ b/sg_wechat_enterprise/we_api/pay/api/micropay.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import random +from datetime import datetime + +from wechatpy.pay.utils import get_external_ip +from wechatpy.pay.base import BaseWeChatPayAPI + + +class WeChatMicroPay(BaseWeChatPayAPI): + def create(self, body, total_fee, auth_code, client_ip=None, out_trade_no=None, detail=None, attach=None, + fee_type='CNY', goods_tag=None, device_info=None, limit_pay=None): + """ + 刷卡支付接口 + :param device_info: 可选,终端设备号(商户自定义,如门店编号) + :param body: 商品描述 + :param detail: 可选,商品详情 + :param attach: 可选,附加数据,在查询API和支付通知中原样返回,该字段主要用于商户携带订单的自定义数据 + :param client_ip: 可选,APP和网页支付提交用户端ip,Native支付填调用微信支付API的机器IP + :param out_trade_no: 可选,商户订单号,默认自动生成 + :param total_fee: 总金额,单位分 + :param fee_type: 可选,符合ISO 4217标准的三位字母代码,默认人民币:CNY + :param goods_tag: 可选,商品标记,代金券或立减优惠功能的参数 + :param limit_pay: 可选,指定支付方式,no_credit--指定不能使用信用卡支付 + :param auth_code: 授权码,扫码支付授权码,设备读取用户微信中的条码或者二维码信息 + :return: 返回的结果数据 + """ + now = datetime.now() + if not out_trade_no: + out_trade_no = '{0}{1}{2}'.format( + self.mch_id, + now.strftime('%Y%m%d%H%M%S'), + random.randint(1000, 10000) + ) + data = { + 'appid': self.appid, + 'device_info': device_info, + 'body': body, + 'detail': detail, + 'attach': attach, + 'out_trade_no': out_trade_no, + 'total_fee': total_fee, + 'fee_type': fee_type, + 'spbill_create_ip': client_ip or get_external_ip(), + 'goods_tag': goods_tag, + 'limit_pay': limit_pay, + 'auth_code': auth_code, + } + return self._post('pay/micropay', data=data) + + def query(self, transaction_id=None, out_trade_no=None): + """ + 查询订单 + + :param transaction_id: 微信的订单号,优先使用 + :param out_trade_no: 商户系统内部的订单号,当没提供transaction_id时需要传这个。 + :return: 返回的结果数据 + """ + data = { + 'appid': self.appid, + 'transaction_id': transaction_id, + 'out_trade_no': out_trade_no, + } + return self._post('pay/orderquery', data=data) diff --git a/sg_wechat_enterprise/we_api/pay/api/order.py b/sg_wechat_enterprise/we_api/pay/api/order.py new file mode 100644 index 00000000..fb233845 --- /dev/null +++ b/sg_wechat_enterprise/we_api/pay/api/order.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import time +import random +from datetime import datetime, timedelta + +from wechatpy.utils import timezone +from wechatpy.pay.utils import get_external_ip +from wechatpy.pay.base import BaseWeChatPayAPI +from wechatpy.utils import random_string, to_text +from wechatpy.pay.utils import calculate_signature + + +class WeChatOrder(BaseWeChatPayAPI): + + def create(self, trade_type, body, total_fee, notify_url, client_ip=None, + user_id=None, out_trade_no=None, detail=None, attach=None, + fee_type='CNY', time_start=None, time_expire=None, + goods_tag=None, product_id=None, device_info=None, limit_pay=None): + """ + 统一下单接口 + + :param trade_type: 交易类型,取值如下:JSAPI,NATIVE,APP,WAP + :param body: 商品描述 + :param total_fee: 总金额,单位分 + :param notify_url: 接收微信支付异步通知回调地址 + :param client_ip: 可选,APP和网页支付提交用户端ip,Native支付填调用微信支付API的机器IP + :param user_id: 可选,用户在商户appid下的唯一标识。trade_type=JSAPI,此参数必传 + :param out_trade_no: 可选,商户订单号,默认自动生成 + :param detail: 可选,商品详情 + :param attach: 可选,附加数据,在查询API和支付通知中原样返回,该字段主要用于商户携带订单的自定义数据 + :param fee_type: 可选,符合ISO 4217标准的三位字母代码,默认人民币:CNY + :param time_start: 可选,订单生成时间,默认为当前时间 + :param time_expire: 可选,订单失效时间,默认为订单生成时间后两小时 + :param goods_tag: 可选,商品标记,代金券或立减优惠功能的参数 + :param product_id: 可选,trade_type=NATIVE,此参数必传。此id为二维码中包含的商品ID,商户自行定义 + :param device_info: 可选,终端设备号(门店号或收银设备ID),注意:PC网页或公众号内支付请传"WEB" + :param limit_pay: 可选,指定支付方式,no_credit--指定不能使用信用卡支付 + :return: 返回的结果数据 + """ + now = datetime.fromtimestamp(time.time(), tz=timezone('Asia/Shanghai')) + hours_later = now + timedelta(hours=2) + if time_start is None: + time_start = now + if time_expire is None: + time_expire = hours_later + if not out_trade_no: + out_trade_no = '{0}{1}{2}'.format( + self.mch_id, + now.strftime('%Y%m%d%H%M%S'), + random.randint(1000, 10000) + ) + data = { + 'appid': self.appid, + 'device_info': device_info, + 'body': body, + 'detail': detail, + 'attach': attach, + 'out_trade_no': out_trade_no, + 'fee_type': fee_type, + 'total_fee': total_fee, + 'spbill_create_ip': client_ip or get_external_ip(), + 'time_start': time_start.strftime('%Y%m%d%H%M%S'), + 'time_expire': time_expire.strftime('%Y%m%d%H%M%S'), + 'goods_tag': goods_tag, + 'notify_url': notify_url, + 'trade_type': trade_type, + 'limit_pay': limit_pay, + 'product_id': product_id, + 'openid': user_id, + } + return self._post('pay/unifiedorder', data=data) + + def query(self, transaction_id=None, out_trade_no=None): + """ + 查询订单 + + :param transaction_id: 微信的订单号,优先使用 + :param out_trade_no: 商户系统内部的订单号,当没提供transaction_id时需要传这个。 + :return: 返回的结果数据 + """ + data = { + 'appid': self.appid, + 'transaction_id': transaction_id, + 'out_trade_no': out_trade_no, + } + return self._post('pay/orderquery', data=data) + + def close(self, out_trade_no): + """ + 关闭订单 + + :param out_trade_no: 商户系统内部的订单号 + :return: 返回的结果数据 + """ + data = { + 'appid': self.appid, + 'out_trade_no': out_trade_no, + } + return self._post('pay/closeorder', data=data) + + def get_appapi_params(self, prepay_id, timestamp=None, nonce_str=None): + """ + 获取 APP 支付参数 + + :param prepay_id: 统一下单接口返回的 prepay_id 参数值 + :param timestamp: 可选,时间戳,默认为当前时间戳 + :param nonce_str: 可选,随机字符串,默认自动生成 + :return: 签名 + """ + data = { + 'appid': self.appid, + 'partnerid': self.mch_id, + 'prepayid': prepay_id, + 'package': 'Sign=WXPay', + 'timestamp': timestamp or to_text(int(time.time())), + 'noncestr': nonce_str or random_string(32) + } + sign = calculate_signature(data, self._client.api_key) + data['sign'] = sign + return data + + def reverse(self, transaction_id=None, out_trade_no=None): + """ + 撤销订单 + + :param transaction_id: 可选,微信的订单号,优先使用 + :param out_trade_no: 可选,商户系统内部的订单号, + transaction_id、out_trade_no二选一, + 如果同时存在优先级:transaction_id> out_trade_no + :return: 返回的结果数据 + """ + data = { + 'appid': self.appid, + 'transaction_id': transaction_id, + 'out_trade_no': out_trade_no, + } + return self._post('secapi/pay/reverse', data=data) diff --git a/sg_wechat_enterprise/we_api/pay/api/redpack.py b/sg_wechat_enterprise/we_api/pay/api/redpack.py new file mode 100644 index 00000000..6c5a1b25 --- /dev/null +++ b/sg_wechat_enterprise/we_api/pay/api/redpack.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import random +from datetime import datetime + +from wechatpy.pay.utils import get_external_ip +from wechatpy.pay.base import BaseWeChatPayAPI + + +class WeChatRedpack(BaseWeChatPayAPI): + + def send(self, user_id, total_amount, send_name, act_name, + wishing, remark, total_num=1, client_ip=None, + nick_name=None, min_value=None, + max_value=None, out_trade_no=None, logo_imgurl=None): + """ + 发送现金红包 + + :param user_id: 接收红包的用户在公众号下的 openid + :param total_amount: 红包金额,单位分 + :param send_name: 商户名称 + :param nick_name: 可选,提供方名称,默认和商户名称相同 + :param act_name: 活动名称 + :param wishing: 红包祝福语 + :param remark: 备注 + :param client_ip: 可选,调用接口的机器 IP 地址 + :param total_num: 可选,红包发放总人数,默认为 1 + :param min_value: 可选,最小红包金额,单位分 + :param max_value: 可选,最大红包金额,单位分 + :param out_trade_no: 可选,商户订单号,默认会自动生成 + :param logo_imgurl: 可选,商户 Logo 的 URL + :return: 返回的结果数据字典 + """ + if not out_trade_no: + now = datetime.now() + out_trade_no = '{0}{1}{2}'.format( + self.mch_id, + now.strftime('%Y%m%d%H%M%S'), + random.randint(1000, 10000) + ) + data = { + 'wxappid': self.appid, + 're_openid': user_id, + 'total_amount': total_amount, + 'nick_name': nick_name or send_name, + 'send_name': send_name, + 'act_name': act_name, + 'wishing': wishing, + 'remark': remark, + 'client_ip': client_ip or get_external_ip(), + 'total_num': total_num, + 'min_value': min_value or total_amount, + 'max_value': max_value or total_amount, + 'mch_billno': out_trade_no, + 'logo_imgurl': logo_imgurl, + } + return self._post('mmpaymkttransfers/sendredpack', data=data) + + def send_group(self, user_id, total_amount, send_name, act_name, wishing, + remark, total_num, client_ip=None, amt_type="ALL_RAND", + amt_list=None, out_trade_no=None, + logo_imgurl=None, watermark_imgurl=None, + banner_imgurl=None): + """ + 发送裂变红包 + + :param user_id: 接收红包的用户在公众号下的 openid + :param total_amount: 红包金额,单位分 + :param send_name: 商户名称 + :param act_name: 活动名称 + :param wishing: 红包祝福语 + :param remark: 备注 + :param total_num: 红包发放总人数 + :param client_ip: 可选,调用接口的机器 IP 地址 + :param amt_type: 可选,红包金额设置方式 + ALL_RAND—全部随机,商户指定总金额和红包发放总人数,由微信支付随机计算出各红包金额 + ALL_SPECIFIED—全部自定义 + SEED_SPECIFIED—种子红包自定义,其他随机 + :param amt_list: 可选,各红包具体金额,自定义金额时必须设置,单位分 + :param out_trade_no: 可选,商户订单号,默认会自动生成 + :param logo_imgurl: 可选,商户 Logo 的 URL + :param watermark_imgurl: 可选,背景水印图片 URL + :param banner_imgurl: 红包详情页面的 banner 图片 URL + :return: 返回的结果数据字典 + """ + if not out_trade_no: + now = datetime.now() + out_trade_no = '{0}{1}{2}'.format( + self._client.mch_id, + now.strftime('%Y%m%d%H%M%S'), + random.randint(1000, 10000) + ) + data = { + 'wxappid': self.appid, + 're_openid': user_id, + 'total_amount': total_amount, + 'send_name': send_name, + 'act_name': act_name, + 'wishing': wishing, + 'remark': remark, + 'total_num': total_num, + 'client_ip': client_ip or get_external_ip(), + 'amt_type': amt_type, + 'amt_list': amt_list, + 'mch_billno': out_trade_no, + 'logo_imgurl': logo_imgurl, + 'watermark_imgurl': watermark_imgurl, + 'banner_imgurl': banner_imgurl, + } + return self._post('mmpaymkttransfers/sendgroupredpack', data=data) + + def query(self, out_trade_no, bill_type='MCHT'): + """ + 查询红包发放记录 + + :param out_trade_no: 商户订单号 + :param bill_type: 可选,订单类型,目前固定为 MCHT + :return: 返回的红包发放记录信息 + """ + data = { + 'mch_billno': out_trade_no, + 'bill_type': bill_type, + 'appid': self.appid, + } + return self._post('mmpaymkttransfers/gethbinfo', data=data) diff --git a/sg_wechat_enterprise/we_api/pay/api/refund.py b/sg_wechat_enterprise/we_api/pay/api/refund.py new file mode 100644 index 00000000..4e6bbc00 --- /dev/null +++ b/sg_wechat_enterprise/we_api/pay/api/refund.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.pay.base import BaseWeChatPayAPI + + +class WeChatRefund(BaseWeChatPayAPI): + + def apply(self, total_fee, refund_fee, out_refund_no, transaction_id=None, + out_trade_no=None, fee_type='CNY', op_user_id=None, + device_info=None,notify_url=None,refund_account='REFUND_SOURCE_UNSETTLED_FUNDS'): + """ + 申请退款 + + :param total_fee: 订单总金额,单位为分 + :param refund_fee: 退款总金额,单位为分 + :param out_refund_no: 商户系统内部的退款单号,商户系统内部唯一,同一退款单号多次请求只退一笔 + :param transaction_id: 可选,微信订单号 + :param out_trade_no: 可选,商户系统内部的订单号,与 transaction_id 二选一 + :param fee_type: 可选,货币类型,符合ISO 4217标准的三位字母代码,默认人民币:CNY + :param op_user_id: 可选,操作员帐号, 默认为商户号 + :param refund_account: 可选,退款资金来源,仅针对老资金流商户使用,默认使用未结算资金退款 + :param device_info: 可选,终端设备号 + :return: 返回的结果数据 + """ + data = { + 'appid': self.appid, + 'device_info': device_info, + 'transaction_id': transaction_id, + 'out_trade_no': out_trade_no, + 'out_refund_no': out_refund_no, + 'total_fee': total_fee, + 'refund_fee': refund_fee, + 'refund_fee_type': fee_type, + 'op_user_id': op_user_id if op_user_id else self.mch_id, + "notify_url":notify_url, + 'refund_account': refund_account, + + } + return self._post('secapi/pay/refund', data=data) + + def query(self, refund_id=None, out_refund_no=None, transaction_id=None, + out_trade_no=None, device_info=None): + """ + 查询退款 + + :param refund_id: 微信退款单号 + :param out_refund_no: 商户退款单号 + :param transaction_id: 微信订单号 + :param out_trade_no: 商户系统内部的订单号 + :param device_info: 可选,终端设备号 + :return: 返回的结果数据 + """ + data = { + 'appid': self.appid, + 'device_info': device_info, + 'transaction_id': transaction_id, + 'out_trade_no': out_trade_no, + 'out_refund_no': out_refund_no, + 'refund_id': refund_id, + } + return self._post('pay/refundquery', data=data) diff --git a/sg_wechat_enterprise/we_api/pay/api/tools.py b/sg_wechat_enterprise/we_api/pay/api/tools.py new file mode 100644 index 00000000..ab794081 --- /dev/null +++ b/sg_wechat_enterprise/we_api/pay/api/tools.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from datetime import datetime, date + +from wechatpy.pay.base import BaseWeChatPayAPI + + +class WeChatTools(BaseWeChatPayAPI): + + def short_url(self, long_url): + """ + 长链接转短链接 + + :param long_url: 长链接 + :return: 返回的结果数据 + """ + data = { + 'appid': self.appid, + 'long_url': long_url, + } + return self._post('tools/shorturl', data=data) + + def download_bill(self, bill_date, bill_type='ALL', device_info=None): + """ + 下载对账单 + + :param bill_date: 下载对账单的日期 + :param bill_type: 账单类型,ALL,返回当日所有订单信息,默认值 + SUCCESS,返回当日成功支付的订单, + REFUND,返回当日退款订单, + REVOKED,已撤销的订单 + :param device_info: 微信支付分配的终端设备号,填写此字段,只下载该设备号的对账单 + :return: 返回的结果数据 + """ + if isinstance(bill_date, (datetime, date)): + bill_date = bill_date.strftime('%Y%m%d') + + data = { + 'appid': self.appid, + 'bill_date': bill_date, + 'bill_type': bill_type, + 'device_info': device_info, + } + return self._post('pay/downloadbill', data=data) + + def auto_code_to_openid(self, auth_code): + """ + 授权码查询 openid 接口 + + :param auth_code: 扫码支付授权码,设备读取用户微信中的条码或者二维码信息 + :return: 返回的结果数据 + """ + data = { + 'appid': self.appid, + 'auth_code': auth_code, + } + return self._post('tools/authcodetoopenid', data=data) diff --git a/sg_wechat_enterprise/we_api/pay/api/transfer.py b/sg_wechat_enterprise/we_api/pay/api/transfer.py new file mode 100644 index 00000000..8e207635 --- /dev/null +++ b/sg_wechat_enterprise/we_api/pay/api/transfer.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import random +from datetime import datetime + +from wechatpy.pay.utils import get_external_ip +from wechatpy.pay.base import BaseWeChatPayAPI + + +class WeChatTransfer(BaseWeChatPayAPI): + + def transfer(self, user_id, amount, desc, client_ip=None, + check_name='OPTION_CHECK', real_name=None, + out_trade_no=None, device_info=None): + """ + 企业付款接口 + + :param user_id: 接受收红包的用户在公众号下的 openid + :param amount: 付款金额,单位分 + :param desc: 付款说明 + :param client_ip: 可选,调用接口机器的 IP 地址 + :param check_name: 可选,校验用户姓名选项, + NO_CHECK:不校验真实姓名, + FORCE_CHECK:强校验真实姓名(未实名认证的用户会校验失败,无法转账), + OPTION_CHECK:针对已实名认证的用户才校验真实姓名(未实名认证用户不校验,可以转账成功), + 默认为 OPTION_CHECK + :param real_name: 可选,收款用户真实姓名, + 如果check_name设置为FORCE_CHECK或OPTION_CHECK,则必填用户真实姓名 + :param out_trade_no: 可选,商户订单号,需保持唯一性,默认自动生成 + :param device_info: 可选,微信支付分配的终端设备号 + :return: 返回的结果信息 + """ + if not out_trade_no: + now = datetime.now() + out_trade_no = '{0}{1}{2}'.format( + self.mch_id, + now.strftime('%Y%m%d%H%M%S'), + random.randint(1000, 10000) + ) + data = { + 'mch_appid': self.appid, + 'mchid': self.mch_id, + 'device_info': device_info, + 'partner_trade_no': out_trade_no, + 'openid': user_id, + 'check_name': check_name, + 're_user_name': real_name, + 'amount': amount, + 'desc': desc, + 'spbill_create_ip': client_ip or get_external_ip(), + } + return self._post('mmpaymkttransfers/promotion/transfers', data=data) + + def query(self, out_trade_no): + """ + 企业付款查询接口 + + :param out_trade_no: 商户调用企业付款API时使用的商户订单号 + :return: 返回的结果数据 + """ + data = { + 'appid': self.appid, + 'partner_trade_no': out_trade_no, + } + return self._post('mmpaymkttransfers/gettransferinfo', data=data) diff --git a/sg_wechat_enterprise/we_api/pay/base.py b/sg_wechat_enterprise/we_api/pay/base.py new file mode 100644 index 00000000..e1d17c28 --- /dev/null +++ b/sg_wechat_enterprise/we_api/pay/base.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + + +class BaseWeChatPayAPI(object): + """ WeChat Pay API base class """ + def __init__(self, client=None): + self._client = client + + def _get(self, url, **kwargs): + if getattr(self, 'API_BASE_URL', None): + kwargs['api_base_url'] = self.API_BASE_URL + return self._client.get(url, **kwargs) + + def _post(self, url, **kwargs): + if getattr(self, 'API_BASE_URL', None): + kwargs['api_base_url'] = self.API_BASE_URL + return self._client.post(url, **kwargs) + + @property + def appid(self): + return self._client.appid + + @property + def mch_id(self): + return self._client.mch_id + + @property + def sub_mch_id(self): + return self._client.sub_mch_id diff --git a/sg_wechat_enterprise/we_api/pay/utils.py b/sg_wechat_enterprise/we_api/pay/utils.py new file mode 100644 index 00000000..18691ca3 --- /dev/null +++ b/sg_wechat_enterprise/we_api/pay/utils.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +import copy +import hashlib +import socket + +import six + +from wechatpy.utils import to_binary, to_text + + +def format_url(params, api_key=None): + data = [to_binary('{0}={1}'.format(k, params[k])) for k in sorted(params) if params[k]] + if api_key: + data.append(to_binary('key={0}'.format(api_key))) + return b"&".join(data) + + +def calculate_signature(params, api_key): + url = format_url(params, api_key) + return to_text(hashlib.md5(url).hexdigest().upper()) + + +def _check_signature(params, api_key): + _params = copy.deepcopy(params) + sign = _params.pop('sign', '') + return sign == calculate_signature(_params, api_key) + + +def dict_to_xml(d, sign): + xml = ['\n'] + for k in sorted(d): + # use sorted to avoid test error on Py3k + v = d[k] + if isinstance(v, six.integer_types) or v.isdigit(): + xml.append('<{0}>{1}\n'.format(to_text(k), to_text(v))) + else: + xml.append( + '<{0}>\n'.format(to_text(k), to_text(v)) + ) + xml.append('\n'.format(to_text(sign))) + return ''.join(xml) + + +def get_external_ip(): + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + wechat_ip = socket.gethostbyname('api.mch.weixin.qq.com') + sock.connect((wechat_ip, 80)) + addr, port = sock.getsockname() + sock.close() + return addr + except socket.error: + return '127.0.0.1' diff --git a/sg_wechat_enterprise/we_api/replies.py b/sg_wechat_enterprise/we_api/replies.py new file mode 100644 index 00000000..cdcef25d --- /dev/null +++ b/sg_wechat_enterprise/we_api/replies.py @@ -0,0 +1,341 @@ +# -*- coding: utf-8 -*- +""" + wechatpy.replies + ~~~~~~~~~~~~~~~~~~ + This module defines all kinds of replies you can send to WeChat + + :copyright: (c) 2014 by messense. + :license: MIT, see LICENSE for more details. +""" +from __future__ import absolute_import, unicode_literals +import time +import six + +from wechatpy.fields import( + StringField, + IntegerField, + ImageField, + VoiceField, + VideoField, + MusicField, + ArticlesField, + Base64EncodeField, + HardwareField, +) +from wechatpy.messages import BaseMessage, MessageMetaClass +from wechatpy.utils import to_text, to_binary + + +REPLY_TYPES = {} + + +def register_reply(reply_type): + def register(cls): + REPLY_TYPES[reply_type] = cls + return cls + return register + + +class BaseReply(six.with_metaclass(MessageMetaClass)): + """Base class for all replies""" + source = StringField('FromUserName') + target = StringField('ToUserName') + time = IntegerField('CreateTime', time.time()) + type = 'unknown' + + def __init__(self, **kwargs): + self._data = {} + message = kwargs.pop('message', None) + if message and isinstance(message, BaseMessage): + if 'source' not in kwargs: + kwargs['source'] = message.target + if 'target' not in kwargs: + kwargs['target'] = message.source + if hasattr(message, 'agent') and 'agent' not in kwargs: + kwargs['agent'] = message.agent + if 'time' not in kwargs: + kwargs['time'] = time.time() + for name, value in kwargs.items(): + field = self._fields.get(name) + if field: + self._data[field.name] = value + else: + setattr(self, name, value) + + def render(self): + """Render reply from Python object to XML string""" + tpl = '\n{data}\n' + nodes = [] + msg_type = ''.format( + msg_type=self.type + ) + nodes.append(msg_type) + for name, field in self._fields.items(): + value = getattr(self, name, field.default) + node_xml = field.to_xml(value) + nodes.append(node_xml) + data = '\n'.join(nodes) + return tpl.format(data=data) + + def __str__(self): + if six.PY2: + return to_binary(self.render()) + else: + return to_text(self.render()) + + +@register_reply('empty') +class EmptyReply(BaseReply): + """ + 回复空串 + + 微信服务器不会对此作任何处理,并且不会发起重试 + """ + def __init__(self): + pass + + def render(self): + return '' + + +@register_reply('text') +class TextReply(BaseReply): + """ + 文本回复 + 详情请参阅 http://mp.weixin.qq.com/wiki/9/2c15b20a16019ae613d413e30cac8ea1.html + """ + type = 'text' + content = StringField('Content') + + +@register_reply('image') +class ImageReply(BaseReply): + """ + 图片回复 + 详情请参阅 + http://mp.weixin.qq.com/wiki/9/2c15b20a16019ae613d413e30cac8ea1.html + """ + type = 'image' + image = ImageField('Image') + + @property + def media_id(self): + return self.image + + @media_id.setter + def media_id(self, value): + self.image = value + + +@register_reply('voice') +class VoiceReply(BaseReply): + """ + 语音回复 + 详情请参阅 + http://mp.weixin.qq.com/wiki/9/2c15b20a16019ae613d413e30cac8ea1.html + """ + type = 'voice' + voice = VoiceField('Voice') + + @property + def media_id(self): + return self.voice + + @media_id.setter + def media_id(self, value): + self.voice = value + + +@register_reply('video') +class VideoReply(BaseReply): + """ + 视频回复 + 详情请参阅 + http://mp.weixin.qq.com/wiki/9/2c15b20a16019ae613d413e30cac8ea1.html + """ + type = 'video' + video = VideoField('Video', {}) + + @property + def media_id(self): + return self.video.get('media_id') + + @media_id.setter + def media_id(self, value): + video = self.video + video['media_id'] = value + self.video = video + + @property + def title(self): + return self.video.get('title') + + @title.setter + def title(self, value): + video = self.video + video['title'] = value + self.video = video + + @property + def description(self): + return self.video.get('description') + + @description.setter + def description(self, value): + video = self.video + video['description'] = value + self.video = video + + +@register_reply('music') +class MusicReply(BaseReply): + """ + 音乐回复 + 详情请参阅 + http://mp.weixin.qq.com/wiki/9/2c15b20a16019ae613d413e30cac8ea1.html + """ + type = 'music' + music = MusicField('Music', {}) + + @property + def thumb_media_id(self): + return self.music.get('thumb_media_id') + + @thumb_media_id.setter + def thumb_media_id(self, value): + music = self.music + music['thumb_media_id'] = value + self.music = music + + @property + def title(self): + return self.music.get('title') + + @title.setter + def title(self, value): + music = self.music + music['title'] = value + self.music = music + + @property + def description(self): + return self.music.get('description') + + @description.setter + def description(self, value): + music = self.music + music['description'] = value + self.music = music + + @property + def music_url(self): + return self.music.get('music_url') + + @music_url.setter + def music_url(self, value): + music = self.music + music['music_url'] = value + self.music = music + + @property + def hq_music_url(self): + return self.music.get('hq_music_url') + + @hq_music_url.setter + def hq_music_url(self, value): + music = self.music + music['hq_music_url'] = value + self.music = music + + +@register_reply('news') +class ArticlesReply(BaseReply): + """ + 图文回复 + 详情请参阅 + http://mp.weixin.qq.com/wiki/9/2c15b20a16019ae613d413e30cac8ea1.html + """ + type = 'news' + articles = ArticlesField('Articles', []) + + def add_article(self, article): + if len(self.articles) == 10: + raise AttributeError("Can't add more than 10 articles" + " in an ArticlesReply") + articles = self.articles + articles.append(article) + self.articles = articles + + +@register_reply('transfer_customer_service') +class TransferCustomerServiceReply(BaseReply): + """ + 将消息转发到多客服 + 详情请参阅 + http://mp.weixin.qq.com/wiki/5/ae230189c9bd07a6b221f48619aeef35.html + """ + type = 'transfer_customer_service' + + +@register_reply('device_text') +class DeviceTextReply(BaseReply): + type = 'device_text' + device_type = StringField('DeviceType') + device_id = StringField('DeviceID') + session_id = StringField('SessionID') + content = Base64EncodeField('Content') + + +@register_reply('device_event') +class DeviceEventReply(BaseReply): + type = 'device_event' + event = StringField('Event') + device_type = StringField('DeviceType') + device_id = StringField('DeviceID') + session_id = StringField('SessionID') + content = Base64EncodeField('Content') + + +@register_reply('device_status') +class DeviceStatusReply(BaseReply): + type = 'device_status' + device_type = StringField('DeviceType') + device_id = StringField('DeviceID') + status = StringField('DeviceStatus') + + +@register_reply('hardware') +class HardwareReply(BaseReply): + type = 'hardware' + func_flag = IntegerField('FuncFlag', 0) + hardware = HardwareField('HardWare') + + +def create_reply(reply, message=None, render=False): + """ + Create a reply quickly + """ + r = None + if not reply: + r = EmptyReply() + elif isinstance(reply, BaseReply): + r = reply + if message: + r.source = message.target + r.target = message.source + elif isinstance(reply, six.string_types): + r = TextReply( + message=message, + content=reply + ) + elif isinstance(reply, (tuple, list)): + if len(reply) > 10: + raise AttributeError("Can't add more than 10 articles" + " in an ArticlesReply") + r = ArticlesReply( + message=message, + articles=reply + ) + if r and render: + return r.render() + return r diff --git a/sg_wechat_enterprise/we_api/session/__init__.py b/sg_wechat_enterprise/we_api/session/__init__.py new file mode 100644 index 00000000..197397bc --- /dev/null +++ b/sg_wechat_enterprise/we_api/session/__init__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + + +class SessionStorage(object): + + def get(self, key, default=None): + raise NotImplementedError() + + def set(self, key, value, ttl=None): + raise NotImplementedError() + + def delete(self, key): + raise NotImplementedError() + + def __getitem__(self, key): + self.get(key) + + def __setitem__(self, key, value): + self.set(key, value) + + def __delitem__(self, key): + self.delete(key) diff --git a/sg_wechat_enterprise/we_api/session/memcachedstorage.py b/sg_wechat_enterprise/we_api/session/memcachedstorage.py new file mode 100644 index 00000000..dda13bc8 --- /dev/null +++ b/sg_wechat_enterprise/we_api/session/memcachedstorage.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.session import SessionStorage +from wechatpy.utils import to_text +from wechatpy.utils import json + + +class MemcachedStorage(SessionStorage): + + def __init__(self, mc, prefix='wechatpy'): + for method_name in ('get', 'set', 'delete'): + assert hasattr(mc, method_name) + self.mc = mc + self.prefix = prefix + + def key_name(self, key): + return '{0}:{1}'.format(self.prefix, key) + + def get(self, key, default=None): + key = self.key_name(key) + value = self.mc.get(key) + if value is None: + return default + return json.loads(to_text(value)) + + def set(self, key, value, ttl=None): + if value is None: + return + key = self.key_name(key) + value = json.dumps(value) + self.mc.set(key, value) + + def delete(self, key): + key = self.key_name(key) + self.mc.delete(key) diff --git a/sg_wechat_enterprise/we_api/session/memorystorage.py b/sg_wechat_enterprise/we_api/session/memorystorage.py new file mode 100644 index 00000000..7eb2e4e6 --- /dev/null +++ b/sg_wechat_enterprise/we_api/session/memorystorage.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.session import SessionStorage + + +class MemoryStorage(SessionStorage): + + def __init__(self): + self._data = {} + + def get(self, key, default=None): + return self._data.get(key, default) + + def set(self, key, value, ttl=None): + if value is None: + return + self._data[key] = value + + def delete(self, key): + self._data.pop(key, None) diff --git a/sg_wechat_enterprise/we_api/session/redisstorage.py b/sg_wechat_enterprise/we_api/session/redisstorage.py new file mode 100644 index 00000000..42171e6f --- /dev/null +++ b/sg_wechat_enterprise/we_api/session/redisstorage.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.session import SessionStorage +from wechatpy.utils import to_text +from wechatpy.utils import json + + +class RedisStorage(SessionStorage): + + def __init__(self, redis, prefix='wechatpy'): + for method_name in ('get', 'set', 'delete'): + assert hasattr(redis, method_name) + self.redis = redis + self.prefix = prefix + + def key_name(self, key): + return '{0}:{1}'.format(self.prefix, key) + + def get(self, key, default=None): + key = self.key_name(key) + value = self.redis.get(key) + if value is None: + return default + return json.loads(to_text(value)) + + def set(self, key, value, ttl=None): + if value is None: + return + key = self.key_name(key) + value = json.dumps(value) + self.redis.set(key, value, ex=ttl) + + def delete(self, key): + key = self.key_name(key) + self.redis.delete(key) diff --git a/sg_wechat_enterprise/we_api/session/shovestorage.py b/sg_wechat_enterprise/we_api/session/shovestorage.py new file mode 100644 index 00000000..7742d71c --- /dev/null +++ b/sg_wechat_enterprise/we_api/session/shovestorage.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals +from wechatpy.session import SessionStorage + + +class ShoveStorage(SessionStorage): + + def __init__(self, shove, prefix='wechatpy'): + self.shove = shove + self.prefix = prefix + + def key_name(self, key): + return '{0}:{1}'.format(self.prefix, key) + + def get(self, key, default=None): + key = self.key_name(key) + try: + return self.shove[key] + except KeyError: + return default + + def set(self, key, value, ttl=None): + if value is None: + return + + key = self.key_name(key) + self.shove[key] = value + + def delete(self, key): + key = self.key_name(key) + try: + del self.shove[key] + except KeyError: + pass diff --git a/sg_wechat_enterprise/we_api/utils.py b/sg_wechat_enterprise/we_api/utils.py new file mode 100644 index 00000000..7e54e1a3 --- /dev/null +++ b/sg_wechat_enterprise/we_api/utils.py @@ -0,0 +1,149 @@ +# -*- coding: utf-8 -*- +""" + wechatpy.utils + ~~~~~~~~~~~~~~~ + + This module provides some useful utilities. + + :copyright: (c) 2014 by messense. + :license: MIT, see LICENSE for more details. +""" +from __future__ import absolute_import, unicode_literals +import six +import six.moves.urllib.parse as urlparse +import sys +import string +import random +import hashlib + +try: + '''Use simplejson if we can, fallback to json otherwise.''' + import simplejson as json +except ImportError: + import json # NOQA + + +class ObjectDict(dict): + """Makes a dictionary behave like an object, with attribute-style access. + """ + + def __getattr__(self, key): + if key in self: + return self[key] + return None + + def __setattr__(self, key, value): + self[key] = value + + +class WeChatSigner(object): + """WeChat data signer""" + + def __init__(self, delimiter=b''): + self._data = [] + self._delimiter = to_binary(delimiter) + + def add_data(self, *args): + """Add data to signer""" + for data in args: + self._data.append(to_binary(data)) + + @property + def signature(self): + """Get data signature""" + self._data.sort() + str_to_sign = self._delimiter.join(self._data) + return hashlib.sha1(str_to_sign).hexdigest() + + +def check_signature(token, signature, timestamp, nonce): + """Check WeChat callback signature, raises InvalidSignatureException + if check failed. + + :param token: WeChat callback token + :param signature: WeChat callback signature sent by WeChat server + :param timestamp: WeChat callback timestamp sent by WeChat server + :param nonce: WeChat callback nonce sent by WeChat sever + """ + signer = WeChatSigner() + signer.add_data(token, timestamp, nonce) + if signer.signature != signature: + from wechatpy.exceptions import InvalidSignatureException + + raise InvalidSignatureException() + + +def to_text(value, encoding='utf-8'): + """Convert value to unicode, default encoding is utf-8 + + :param value: Value to be converted + :param encoding: Desired encoding + """ + if not value: + return '' + if isinstance(value, six.text_type): + return value + if isinstance(value, six.binary_type): + return value.decode(encoding) + return six.text_type(value) + + +def to_binary(value, encoding='utf-8'): + """Convert value to binary string, default encoding is utf-8 + + :param value: Value to be converted + :param encoding: Desired encoding + """ + if not value: + return b'' + if isinstance(value, six.binary_type): + return value + if isinstance(value, six.text_type): + return value.encode(encoding) + return six.binary_type(value) + + +def timezone(zone): + """Try to get timezone using pytz or python-dateutil + + :param zone: timezone str + :return: timezone tzinfo or None + """ + try: + import pytz + return pytz.timezone(zone) + except ImportError: + pass + try: + from dateutil.tz import gettz + return gettz(zone) + except ImportError: + return None + + +def random_string(length=16): + rule = string.ascii_letters + string.digits + rand_list = random.sample(rule, length) + return ''.join(rand_list) + + +def get_querystring(uri): + """Get Qeruystring information from uri. + + :param uri: uri + :return: querystring info or {} + """ + parts = urlparse.urlsplit(uri) + if sys.version_info[:2] == (2, 6): + query = parts.path + if query.startswith('?'): + query = query[1:] + else: + query = parts.query + return urlparse.parse_qs(query) + + +def byte2int(c): + if six.PY2: + return ord(c) + return c diff --git a/web_widget_model_viewer/static/src/js/3d_viewer.js b/web_widget_model_viewer/static/src/js/3d_viewer.js index 15374070..4ed9dcc1 100644 --- a/web_widget_model_viewer/static/src/js/3d_viewer.js +++ b/web_widget_model_viewer/static/src/js/3d_viewer.js @@ -10,11 +10,18 @@ import {session} from "@web/session"; // var QWeb = core.qweb; -import {Component} from "@odoo/owl"; +import {Component, onPatched} from "@odoo/owl"; export class StepViewer extends Component { setup() { this.props.url = this.formatUrl(); + onPatched(() => { + this.props.url = this.formatUrl(); + const dom = $(this.__owl__.bdom.parentEl).children('model-viewer') + if(dom && dom.length) { + dom.attr('src', this.formatUrl()) + } + }) } formatUrl() {