From 6b22476e20fb7eade959194bfabd94d353e2d5ff Mon Sep 17 00:00:00 2001 From: Antony Lesuisse <al@openerp.com> Date: Wed, 4 Oct 2017 20:05:05 +0200 Subject: [PATCH] [ADD] iap: In app purchase IAP allow app publishers to charge for ongoing services. In that context, Odoo acts mostly as a payment platform between the service user (client) and the service provider (Odoo App developer). --- addons/iap/__init__.py | 4 + addons/iap/__manifest__.py | 23 ++ addons/iap/models/__init__.py | 4 + addons/iap/models/iap.py | 157 ++++++++++++ addons/iap/security/ir.model.access.csv | 3 + addons/iap/security/ir_rule.xml | 13 + addons/iap/static/src/js/crash_manager.js | 49 ++++ addons/iap/static/src/js/dashboard.js | 29 +++ addons/iap/static/src/js/iap_credit.js | 28 +++ addons/iap/static/src/xml/iap_templates.xml | 26 ++ addons/iap/views/assets.xml | 10 + addons/iap/views/iap_views.xml | 53 ++++ .../static/src/js/dashboard.js | 1 + doc/_extensions/odoo_ext/static/layout.less | 4 +- doc/_extensions/odoo_ext/static/style.css | 24 +- doc/_extensions/odoo_ext/translator.py | 5 + doc/index.rst | 2 +- doc/reference.rst | 1 - doc/webservices.rst | 13 + doc/webservices/flow.png | Bin 0 -> 14726 bytes doc/webservices/iap.rst | 235 ++++++++++++++++++ .../odoo.rst} | 6 +- .../upgrade.rst} | 6 +- 23 files changed, 674 insertions(+), 22 deletions(-) create mode 100644 addons/iap/__init__.py create mode 100644 addons/iap/__manifest__.py create mode 100644 addons/iap/models/__init__.py create mode 100644 addons/iap/models/iap.py create mode 100644 addons/iap/security/ir.model.access.csv create mode 100644 addons/iap/security/ir_rule.xml create mode 100644 addons/iap/static/src/js/crash_manager.js create mode 100644 addons/iap/static/src/js/dashboard.js create mode 100644 addons/iap/static/src/js/iap_credit.js create mode 100644 addons/iap/static/src/xml/iap_templates.xml create mode 100644 addons/iap/views/assets.xml create mode 100644 addons/iap/views/iap_views.xml create mode 100644 doc/webservices.rst create mode 100644 doc/webservices/flow.png create mode 100644 doc/webservices/iap.rst rename doc/{api_integration.rst => webservices/odoo.rst} (99%) rename doc/{reference/upgrade_api.rst => webservices/upgrade.rst} (99%) diff --git a/addons/iap/__init__.py b/addons/iap/__init__.py new file mode 100644 index 000000000000..dc5e6b693d19 --- /dev/null +++ b/addons/iap/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/addons/iap/__manifest__.py b/addons/iap/__manifest__.py new file mode 100644 index 000000000000..35124df76ebc --- /dev/null +++ b/addons/iap/__manifest__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + + +{ + 'name': 'In-App Purchases', + 'category': 'Tools', + 'summary': 'Basic models and helpers to support In-App purchases.', + 'description': """ +This module provides standard tools (account model, context manager and helpers) to support In-App purchases inside Odoo. +""", + 'depends': ['web'], + 'data': [ + 'security/ir.model.access.csv', + 'security/ir_rule.xml', + 'views/assets.xml', + 'views/iap_views.xml', + ], + 'qweb': [ + 'static/src/xml/iap_templates.xml', + ], + 'auto_install': True, +} diff --git a/addons/iap/models/__init__.py b/addons/iap/models/__init__.py new file mode 100644 index 000000000000..4af61c83a0d7 --- /dev/null +++ b/addons/iap/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import iap diff --git a/addons/iap/models/iap.py b/addons/iap/models/iap.py new file mode 100644 index 000000000000..87394fd053b1 --- /dev/null +++ b/addons/iap/models/iap.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +import contextlib +import logging +import json +import uuid + +import werkzeug.urls +import requests +from requests.packages import urllib3 + +from odoo import api, fields, models, exceptions + +_logger = logging.getLogger(__name__) + +DEFAULT_ENDPOINT = 'https://iap.odoo.com' + + +#---------------------------------------------------------- +# Helpers for both clients and proxy +#---------------------------------------------------------- +def get_endpoint(env): + url = env['ir.config_parameter'].sudo().get_param('iap.endpoint', DEFAULT_ENDPOINT) + return url + + +#---------------------------------------------------------- +# Helpers for clients +#---------------------------------------------------------- +class InsufficientCreditError(Exception): + pass + + +class AuthenticationError(Exception): + pass + + +def jsonrpc(url, method='call', params=None): + """ + Calls the provided JSON-RPC endpoint, unwraps the result and + returns JSON-RPC errors as exceptions. + """ + payload = { + 'jsonrpc': '2.0', + 'method': method, + 'params': params, + 'id': uuid.uuid4().hex, + } + + + _logger.info('iap jsonrpc %s', url) + try: + req = requests.post(url, json=payload) + response = req.json() + if 'error' in response: + name = response['error']['data'].get('name').rpartition('.')[-1] + message = response['error']['data'].get('message') + if name == 'InsufficientCreditError': + e_class = InsufficientCreditError + elif name == 'AccessError': + e_class = exceptions.AccessError + else: + e_class = exceptions.UserError + e = e_class(message) + e.data = response['error']['data'] + raise e + return response.get('result') + except (ValueError, requests.exceptions.ConnectionError, requests.exceptions.MissingSchema, urllib3.exceptions.MaxRetryError) as e: + raise exceptions.AccessError('The url that this service requested returned an error. Please contact the author the app. The url it tried to contact was ' + url) + +#---------------------------------------------------------- +# Helpers for proxy +#---------------------------------------------------------- +@contextlib.contextmanager +def charge(env, key, account_token, credit, description=None, credit_template=None): + """ + Account charge context manager: takes a hold for ``credit`` + amount before executing the body, then captures it if there + is no error, or cancels it if the body generates an exception. + + :param str key: service identifier + :param str account_token: user identifier + :param int credit: cost of the body's operation + :param str description: + """ + end_point = get_endpoint(env) + params = { + 'account_token': account_token, + 'credit': credit, + 'key': key, + 'description': description, + } + try: + transaction_token = jsonrpc(endpoint + '/iap/1/authorize', params=params) + except InsufficientCreditError as e: + if credit_template: + arguments = json.loads(e.args[0]) + arguments['body'] = env['ir.qweb'].render(credit_template) + e.args = (json.dumps(arguments),) + + try: + yield + except Exception as e: + params = { + 'token': transaction_token, + 'key': key, + } + r = jsonrpc(end_point + '/iap/1/cancel', params=params) + raise e + else: + params = { + 'token': transaction_token, + 'key': key, + } + r = jsonrpc(end_point + '/iap/1/capture', params=params) # noqa + + +#---------------------------------------------------------- +# Models for client +#---------------------------------------------------------- +class IapAccount(models.Model): + _name = 'iap.account' + _rec_name = 'service_name' + + service_name = fields.Char() + account_token = fields.Char(default=lambda s: uuid.uuid4().hex) + company_id = fields.Many2one('res.company', default=lambda self: self.env.user.company_id) + + @api.model + def get(self, service_name): + account = self.search([('service_name', '=', service_name), ('company_id', 'in', [self.env.user.company_id.id, False])]) + if not account: + account = self.create({'service_name': service_name}) + # Since the account did not exist yet, we will encounter a NoCreditError, + # which is going to rollback the database and undo the account creation, + # preventing the process to continue any further. + self.env.cr.commit() + return account + + @api.model + def get_credits_url(self, base_url, service_name, credit): + dbuuid = self.env['ir.config_parameter'].sudo().get_param('database.uuid') + account_token = self.get(service_name).account_token + d = { + 'dbuuid': dbuuid, + 'service_name': service_name, + 'account_token': account_token, + 'credit': credit, + } + return '%s?%s' % (base_url, werkzeug.urls.url_encode(d)) + + @api.model + def get_account_url(self): + route = '/iap/services' + endpoint = get_endpoint(self.env) + d = {'dbuuid': self.env['ir.config_parameter'].sudo().get_param('database.uuid')} + + return '%s?%s' % (endpoint + route, werkzeug.urls.url_encode(d)) diff --git a/addons/iap/security/ir.model.access.csv b/addons/iap/security/ir.model.access.csv new file mode 100644 index 000000000000..0eeb34c40cd5 --- /dev/null +++ b/addons/iap/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_client_iap_account_manager,iap.account.manager,model_iap_account,base.group_system,1,1,1,0 +access_client_iap_account_user,iap.account.user,model_iap_account,base.group_user,1,0,0,0 \ No newline at end of file diff --git a/addons/iap/security/ir_rule.xml b/addons/iap/security/ir_rule.xml new file mode 100644 index 000000000000..2935ce2b7461 --- /dev/null +++ b/addons/iap/security/ir_rule.xml @@ -0,0 +1,13 @@ +<odoo> + <data> + <record id="user_iap_account" model="ir.rule"> + <field name="name">User IAP Account</field> + <field name="model_id" ref="model_iap_account"/> + <field name="groups" eval="[(4, ref('base.group_user'))]"/> + <!-- partners can CUD services linked to themselves --> + <field name="domain_force">[ + ('company_id', 'in', [user.company_id.id, False]), + ]</field> + </record> + </data> +</odoo> \ No newline at end of file diff --git a/addons/iap/static/src/js/crash_manager.js b/addons/iap/static/src/js/crash_manager.js new file mode 100644 index 000000000000..3c8d330013ea --- /dev/null +++ b/addons/iap/static/src/js/crash_manager.js @@ -0,0 +1,49 @@ +odoo.define('iap.CrashManager', function (require) { +"use strict"; + +var ajax = require('web.ajax'); +var core = require('web.core'); +var CrashManager = require('web.CrashManager'); +var Dialog = require('web.Dialog'); + +var _t = core._t; +var QWeb = core.qweb; + +CrashManager.include({ + /** + * @override + */ + rpc_error: function (error) { + if (error.data.name === "odoo.addons.iap.models.iap.InsufficientCreditError") { + var error_data = JSON.parse(error.data.message); + ajax.jsonRpc('/web/dataset/call_kw', 'call', { + model: 'iap.account', + method: 'get_credits_url', + args: [], + kwargs: { + base_url: error_data.base_url, + service_name: error_data.service_name, + credit: error_data.credit, + } + }).then(function (url) { + new Dialog(this, { + size: 'large', + title: error_data.title || _t("Insufficient Balance"), + $content: $(QWeb.render('iap.redirect_to_odoo_credit', { + data: error_data, + })).css('padding', 0), + buttons: [ + {text: 'Buy credits at Odoo', classes : "btn-primary", click: function() { + window.open(url, '_blank'); + }, close:true}, + {text: _t("Cancel"), close: true} + ], + }).open(); + }); + } else { + this._super.apply(this, arguments); + } + }, +}); + +}); diff --git a/addons/iap/static/src/js/dashboard.js b/addons/iap/static/src/js/dashboard.js new file mode 100644 index 000000000000..b3490da2eda2 --- /dev/null +++ b/addons/iap/static/src/js/dashboard.js @@ -0,0 +1,29 @@ +odoo.define('iap.Dashboard', function (require) { +"use strict"; + +var ajax = require('web.ajax'); +var core = require('web.core'); +var Dashboard = require('web_settings_dashboard'); + +var _t = core._t; +var QWeb = core.qweb; + +Dashboard.Dashboard.include({ + /** + * @override + */ + load_apps: function (data) { + var _super = this._super.bind(this); + return ajax.jsonRpc('/web/dataset/call_kw', 'call', { + model: 'iap.account', + method: 'get_account_url', + args: [], + kwargs: {}, + }).then(function (url) { + data.apps.url = url; + return _super(data); + }); + }, +}); + +}); diff --git a/addons/iap/static/src/js/iap_credit.js b/addons/iap/static/src/js/iap_credit.js new file mode 100644 index 000000000000..37cddd72aa8c --- /dev/null +++ b/addons/iap/static/src/js/iap_credit.js @@ -0,0 +1,28 @@ +odoo.define('iap.redirect_odoo_credit_widget', function(require) { +"use strict"; + +var core = require('web.core'); +var framework = require('web.framework'); +var Widget = require('web.Widget'); +var QWeb = core.qweb; + + +var IapOdooCreditRedirect = Widget.extend({ + template: 'iap.redirect_to_odoo_credit', + events : { + "click .redirect_confirm" : "odoo_redirect", + }, + init: function (parent, action) { + this._super(parent, action); + this.url = action.params.url; + }, + + odoo_redirect: function () { + window.open(this.url, '_blank'); + this.do_action({type: 'ir.actions.act_window_close'}); + // framework.redirect(this.url); + }, + +}); +core.action_registry.add('iap_odoo_credit_redirect', IapOdooCreditRedirect); +}); diff --git a/addons/iap/static/src/xml/iap_templates.xml b/addons/iap/static/src/xml/iap_templates.xml new file mode 100644 index 000000000000..9b52a4ec445b --- /dev/null +++ b/addons/iap/static/src/xml/iap_templates.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="UTF-8"?> +<template id="template" xml:space="preserve"> + + <!-- LAYOUT TEMPLATES --> + <div t-name="iap.redirect_to_odoo_credit"> + <t t-if="data.body"> + <div t-raw="data.body"/> + </t> + <t t-if="!data.body"> + <t t-if="data.message"> + <span t-esc="data.message"/> + </t> + <t t-if="!data.message"> + <span>Insufficient credit to perform this service.</span> + </t> + </t> + </div> + + <t t-extend="DashboardApps"> + <t t-jquery=".o_web_settings_dashboard_pills" t-operation="append"> + <a class="pull-right" t-att-href="widget.data.url" target="_blank"> + <i class="fa fa-money fa-2x text-muted"/> In-App Purchases</a> + </t> + </t> + +</template> diff --git a/addons/iap/views/assets.xml b/addons/iap/views/assets.xml new file mode 100644 index 000000000000..0e7294ca5867 --- /dev/null +++ b/addons/iap/views/assets.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <template id="assets_backend" name="iap assets" inherit_id="web.assets_backend"> + <xpath expr="." position="inside"> + <script type="text/javascript" src="/iap/static/src/js/iap_credit.js"></script> + <script type="text/javascript" src="/iap/static/src/js/crash_manager.js"></script> + <script type="text/javascript" src="/iap/static/src/js/dashboard.js"></script> + </xpath> + </template> +</odoo> diff --git a/addons/iap/views/iap_views.xml b/addons/iap/views/iap_views.xml new file mode 100644 index 000000000000..ebd64fedee8f --- /dev/null +++ b/addons/iap/views/iap_views.xml @@ -0,0 +1,53 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + + <!-- iap Client Account Views --> + <record id="iap_account_view_form" model="ir.ui.view"> + <field name="name">iap.account.form</field> + <field name="model">iap.account</field> + <field name="arch" type="xml"> + <form string="IAP Account"> + <sheet> + <group name="account" string="Account Information"> + <field name="service_name"/> + <field name="company_id"/> + <field name="account_token"/> + </group> + </sheet> + </form> + </field> + </record> + <record id="iap_account_view_tree" model="ir.ui.view"> + <field name="name">iap.account.tree</field> + <field name="model">iap.account</field> + <field name="arch" type="xml"> + <tree string="IAP Accounts"> + <field name="service_name"/> + <field name="company_id"/> + <field name="account_token" readonly="1"/> + </tree> + </field> + </record> + <!-- Actions --> + <record id="iap_account_action" model="ir.actions.act_window"> + <field name="name">IAP Account</field> + <field name="res_model">iap.account</field> + <field name='view_type'>form</field> + <field name='view_mode'>tree,form</field> + </record> + + <!-- Menus --> + <menuitem + id="iap_root_menu" + name="IAP" + parent="base.menu_custom" + sequence="4"/> + + <menuitem + id="iap_account_menu" + name="IAP Accounts" + parent="iap_root_menu" + action="iap_account_action" + sequence="10"/> + +</odoo> diff --git a/addons/web_settings_dashboard/static/src/js/dashboard.js b/addons/web_settings_dashboard/static/src/js/dashboard.js index 060f7dca6127..7848b4934fd1 100644 --- a/addons/web_settings_dashboard/static/src/js/dashboard.js +++ b/addons/web_settings_dashboard/static/src/js/dashboard.js @@ -345,6 +345,7 @@ core.action_registry.add('web_settings_dashboard.main', Dashboard); return { Dashboard: Dashboard, + DashboardApps: DashboardApps, DashboardInvitations: DashboardInvitations, DashboardPlanner: DashboardPlanner, DashboardShare: DashboardShare, diff --git a/doc/_extensions/odoo_ext/static/layout.less b/doc/_extensions/odoo_ext/static/layout.less index c350defa2a1b..4c8a5c643869 100644 --- a/doc/_extensions/odoo_ext/static/layout.less +++ b/doc/_extensions/odoo_ext/static/layout.less @@ -103,7 +103,7 @@ main.has_code_col{ > *{ max-width: 100%; } - section { + > section { position: relative; display:block; float: left; @@ -116,7 +116,7 @@ main.has_code_col{ &:before { .code-col(); } - section { + > section { > * { width: 54.633333%; max-width: 600px; diff --git a/doc/_extensions/odoo_ext/static/style.css b/doc/_extensions/odoo_ext/static/style.css index ead78f9101dd..1442d4bdd675 100644 --- a/doc/_extensions/odoo_ext/static/style.css +++ b/doc/_extensions/odoo_ext/static/style.css @@ -9708,7 +9708,7 @@ main.has_code_col article.doc-body { main.has_code_col article.doc-body > * { max-width: 100%; } -main.has_code_col article.doc-body section { +main.has_code_col article.doc-body > section { position: relative; display: block; float: left; @@ -9730,36 +9730,36 @@ main.has_code_col article.doc-body section { top: 0; right: 0; } - main.has_code_col article.doc-body section > * { + main.has_code_col article.doc-body > section > * { width: 54.633333%; max-width: 600px; float: left; clear: left; } - main.has_code_col article.doc-body section > h1, - main.has_code_col article.doc-body section > h2, - main.has_code_col article.doc-body section > h3, - main.has_code_col article.doc-body section > h4, - main.has_code_col article.doc-body section > h5, - main.has_code_col article.doc-body section > h6 { + main.has_code_col article.doc-body > section > h1, + main.has_code_col article.doc-body > section > h2, + main.has_code_col article.doc-body > section > h3, + main.has_code_col article.doc-body > section > h4, + main.has_code_col article.doc-body > section > h5, + main.has_code_col article.doc-body > section > h6 { width: 100%; float: none; clear: none; } - main.has_code_col article.doc-body section .doc-aside { + main.has_code_col article.doc-body > section .doc-aside { width: 41%; float: none; clear: none; margin-right: 15px; margin-left: 57%; } - main.has_code_col article.doc-body section .doc-aside .content-switcher { + main.has_code_col article.doc-body > section .doc-aside .content-switcher { margin-top: 0; } - main.has_code_col article.doc-body section .doc-aside .content-switcher > ul { + main.has_code_col article.doc-body > section .doc-aside .content-switcher > ul { margin-bottom: 0; } - main.has_code_col article.doc-body section .doc-aside .content-switcher > ul > li { + main.has_code_col article.doc-body > section .doc-aside .content-switcher > ul > li { color: #dcddde; } } diff --git a/doc/_extensions/odoo_ext/translator.py b/doc/_extensions/odoo_ext/translator.py index 443458f33c77..c2349bca315b 100644 --- a/doc/_extensions/odoo_ext/translator.py +++ b/doc/_extensions/odoo_ext/translator.py @@ -142,6 +142,11 @@ class BootstrapTranslator(nodes.NodeVisitor, object): if not self.section_level: self.body.append(u'</section>') + def visit_topic(self, node): + self.body.append(self.starttag(node, 'nav')) + def depart_topic(self, node): + self.body.append(u'</nav>') + def is_compact_paragraph(self, node): parent = node.parent if isinstance(parent, (nodes.document, nodes.compound, diff --git a/doc/index.rst b/doc/index.rst index 21231b7ce3fa..8035da789f9e 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -14,7 +14,7 @@ Index :maxdepth: 2 tutorials - api_integration + webservices setup reference diff --git a/doc/reference.rst b/doc/reference.rst index d83ef3bb4768..1b41f4cdbd24 100644 --- a/doc/reference.rst +++ b/doc/reference.rst @@ -21,5 +21,4 @@ Reference reference/reports reference/mixins reference/guidelines - reference/upgrade_api reference/mobile diff --git a/doc/webservices.rst b/doc/webservices.rst new file mode 100644 index 000000000000..3dad86448ba7 --- /dev/null +++ b/doc/webservices.rst @@ -0,0 +1,13 @@ +:banner: banners/web_service_api.jpg +:types: api + +============ +Web Services +============ + +.. toctree:: + :titlesonly: + + webservices/odoo + webservices/iap + webservices/upgrade diff --git a/doc/webservices/flow.png b/doc/webservices/flow.png new file mode 100644 index 0000000000000000000000000000000000000000..df4bf7cfbbbc24ecda679650eb5c13a467c93659 GIT binary patch literal 14726 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 J{?P9Ee*t>7i<AHW literal 0 HcmV?d00001 diff --git a/doc/webservices/iap.rst b/doc/webservices/iap.rst new file mode 100644 index 000000000000..18af76900fb8 --- /dev/null +++ b/doc/webservices/iap.rst @@ -0,0 +1,235 @@ +:types: api + + +:code-column: + +.. _webservices/iap: + +================ +In-App Purchases +================ + +IAP allow providers of ongoing services through Odoo apps to be compensated +for ongoing service use rather than — and possibly instead of — a sole initial +purchase. + +In that context, Odoo acts mostly as a *broker* between the service user +(client) and the service provider (Odoo App developer): + +* users purchase service tokens from Odoo +* service providers draw tokens from the user's Odoo account + +.. attention:: + + This document is intended for *service providers* and presents the latter, + which can be done either via direct JSON-RPC2_ or if you are using Odoo + using the convenience helpers it provides. + +.. image:: flow.png + :align: center + +.. contents:: + :local: + +JSON-RPC2_ Transaction API +========================== + +* The IAP transaction API does not require using Odoo when implementing your + server gateway, calls are standard JSON-RPC2_. +* Calls use different *endpoints* but the same *method* on all endpoints + (``call``). +* Exceptions are returned as JSON-RPC2_ errors, the formal exception name is + available on ``data.name`` for programmatic manipulation. + +.. class:: ServiceKey + + Identifier generated for the provider's service. Each key (and service) + matches a token of a fixed value, as generated by the service provide. + + Multiple types of tokens correspond to multiple services e.g. SMS and MMS + could either be the same service (with an MMS being "worth" multiple SMS) + or could be separate services at separate price points. + + .. danger:: your service key *is a secret*, leaking your service key + allows other application developers to draw credits bought for + your service(s) + +.. class:: UserToken + + Identifier for a user account. + +.. class:: TransactionToken + + Transaction identifier, returned by the authorization process and consumed + by either capturing or cancelling the transaction + +.. exception:: odoo.addons.iap.models.iap.NoCreditError + + Raised during transaction authorization if the credits requested are not + currently available on the account (either not enough credits or too many + pending transactions/existing holds). + +.. exception:: odoo.addons.iap.models.iap.BadAuthError + + Raised by any operation to which a service token is required, if the + service token is invalid. + +Authorize +--------- + +.. function:: /iap/1/authorize + + Verifies that the user's account has at least as ``credit`` available + *and creates a hold (pending transaction) on that amount*. + + Any amount currently on hold by a pending transaction is considered + unavailable to further authorize calls. + + Returns a :class:`TransactionToken` identifying the pending transaction + which can be used to capture (confirm) or cancel said transaction. + + :param ServiceKey key: + :param UserToken account_token: + :param int credit: + :param str description: optional, helps users identify the reason for + charges on their accounts. + :returns: :class:`TransactionToken` if the authorization succeeded. + :raises: :class:`~odoo.addons.iap.models.iap.BadAuthError` if the service token is invalid + :raises: :class:`~odoo.addons.iap.models.iap.NoCreditError` if the account does + :raises: ``TypeError`` if the ``credit`` value is not an integer + +.. rst-class:: doc-aside + +.. code-block:: python + + r = requests.post(ODOO + '/iap/1/authorize', json={ + 'jsonrpc': '2.0', + 'id': None, + 'method': 'call', + 'params': { + 'account_token': user_account, + 'key': SERVICE_KEY, + 'credit': 25, + 'description': "Why this is being charged", + } + }).json() + if 'error' in r: + # handle authorize error + tx = r['result'] + + # provide your service here + +Capture +------- + +.. function:: /iap/1/capture + + Confirms the specified transaction, transferring the reserved credits from + the user's account to the service provider's. + + Capture calls are idempotent: performing capture calls on an already + captured transaction has no further effect. + + :param TransactionToken token: + :param ServiceKey key: + :raises: :class:`~odoo.addons.iap.models.iap.BadAuthError` + +.. rst-class:: doc-aside + +.. code-block:: python + + r2 = requests.post(ODOO + '/iap/1/capture', json={ + 'jsonrpc': '2.0', + 'id': None, + 'method': 'call', + 'params': { + 'token': tx, + 'key': SERVICE_KEY, + } + }).json() + if 'error' in r: + # handle capture error + # otherwise transaction is captured + +Cancel +------ + +.. function:: /iap/1/cancel + + Cancels the specified transaction, releasing the hold on the user's + credits. + + Cancel calls are idempotent: performing capture calls on an already + cancelled transaction has no further effect. + + :param TransactionToken token: + :param ServiceKey key: + :raises: :class:`~odoo.addons.iap.models.iap.BadAuthError` + +.. rst-class:: doc-aside + +.. code-block:: python + + r2 = requests.post(ODOO + '/iap/1/cancel', json={ + 'jsonrpc': '2.0', + 'id': None, + 'method': 'call', + 'params': { + 'token': tx, + 'key': SERVICE_KEY, + } + }).json() + if 'error' in r: + # handle cancel error + # otherwise transaction is cancelled + +Odoo Helpers +============ + +For convenience, if you are implementing your service using Odoo the ``iap`` +module provides a few helpers to make IAP flow even simpler: + +Charging +-------- + +.. class:: odoo.addons.iap.models.iap.charge(env, key, account_token, credit[, description]) + + A *context manager* for authorizing and automatically capturing or + cancelling transactions for use in the backend/proxy. + + Works much like e.g. a cursor context manager: + + * immediately authorizes a transaction with the specified parameters + * executes the ``with`` body + * if the body executes in full without error, captures the transaction + * otherwise cancels it + + :param odoo.api.Environment env: used to retrieve the ``iap.endpoint`` + configuration key + :param ServiceKey key: + :param UserToken token: + :param int credit: + :param str description: + +.. rst-class:: doc-aside + +.. code-block:: python + + @route('/deathstar/superlaser', type='json') + def superlaser(self, user_account, + coordinates, target, + factor=1.0): + """ + :param factor: superlaser power factor, + 0.0 is none, 1.0 is full power + """ + credits = int(MAXIMUM_POWER * factor) + with charge(request.env, SERVICE_KEY, user_account, credits): + # TODO: allow other targets + self.env['systems.planets'].search([ + ('grid', '=', 'M-10'), + ('name', '=', 'Alderaan'), + ]).unlink() + + +.. _JSON-RPC2: http://www.jsonrpc.org/specification diff --git a/doc/api_integration.rst b/doc/webservices/odoo.rst similarity index 99% rename from doc/api_integration.rst rename to doc/webservices/odoo.rst index 612e91ed8542..f68862626a75 100644 --- a/doc/api_integration.rst +++ b/doc/webservices/odoo.rst @@ -4,9 +4,9 @@ :code-column: -=============== -Web Service API -=============== +=========================== +Odoo Remote Procedure Calls +=========================== Odoo is usually extended internally via modules, but many of its features and all of its data are also available from the outside for external analysis or diff --git a/doc/reference/upgrade_api.rst b/doc/webservices/upgrade.rst similarity index 99% rename from doc/reference/upgrade_api.rst rename to doc/webservices/upgrade.rst index 04edf919e83b..fa168935b238 100644 --- a/doc/reference/upgrade_api.rst +++ b/doc/webservices/upgrade.rst @@ -6,9 +6,9 @@ .. _reference/upgrade-api: -=========== -Upgrade API -=========== +================ +Database Upgrade +================ Introduction ~~~~~~~~~~~~ -- GitLab